Add configurable mouse and wheel bindings, window move and resize, tab drops, cross-output dragging, and device settings. Adjust display scaling in 0.05 increments. Preserve opposite-edge positioning during scrolling resizes and show rows for edge drops into tabbed columns.
1355 lines
50 KiB
Rust
1355 lines
50 KiB
Rust
use super::{
|
|
AnimatedValue, ClientSize, Duration, Ferese, HashMap, Output, SpringConfig,
|
|
ViewportPresentation, WindowGeometry, WindowId,
|
|
};
|
|
|
|
/// Output-local presentation values. Sampling never changes authoritative
|
|
/// geometry, configure barriers, animation clocks, or another output's view.
|
|
pub(crate) struct FrameScene {
|
|
pub windows: HashMap<WindowId, WindowFrame>,
|
|
pub capture_tabs: Option<Vec<ferese_layout::TabBar>>,
|
|
pub overview: crate::overview::OverviewMotion,
|
|
// Plane selection and scheduling reuse the activity sampled with this scene.
|
|
pub animating: bool,
|
|
pub delta: Duration,
|
|
}
|
|
|
|
pub(crate) struct WindowFrame {
|
|
pub geometry: WindowGeometry,
|
|
pub presentation: crate::presentation::WindowPresentation,
|
|
}
|
|
|
|
impl Ferese {
|
|
pub(crate) fn current_window_presentation(
|
|
&self,
|
|
id: WindowId,
|
|
) -> Option<crate::presentation::WindowPresentation> {
|
|
let geometry = *self.windows.geometry(&id)?;
|
|
Some(self.compose_window_presentation(
|
|
id,
|
|
geometry,
|
|
self.overview.motion(),
|
|
self.workspace_slide_offset(id),
|
|
Duration::ZERO,
|
|
))
|
|
}
|
|
|
|
fn window_focus_effect(
|
|
&self,
|
|
id: WindowId,
|
|
delta: Duration,
|
|
overview_opacity: f32,
|
|
) -> (f32, f64) {
|
|
let target = crate::focus_effect::target(self.focus_effect, self.focused_window, id);
|
|
let mut focus = self
|
|
.windows
|
|
.record(id)
|
|
.and_then(|record| record.focus_transition.clone())
|
|
.unwrap_or_else(|| crate::focus_effect::BoundedFade::new(target));
|
|
focus.advance_visual(
|
|
target,
|
|
delta,
|
|
if self.animations_enabled && self.focus_effect.enabled {
|
|
self.focus_effect.duration_ms
|
|
} else {
|
|
0.0
|
|
},
|
|
);
|
|
self.focus_effect.resolve(
|
|
focus.current,
|
|
self.focused_window.is_some(),
|
|
overview_opacity,
|
|
)
|
|
}
|
|
|
|
fn compose_window_presentation(
|
|
&self,
|
|
id: WindowId,
|
|
geometry: WindowGeometry,
|
|
overview: &crate::overview::OverviewMotion,
|
|
offset: (f64, f64),
|
|
delta: Duration,
|
|
) -> crate::presentation::WindowPresentation {
|
|
let record = self
|
|
.windows
|
|
.record(id)
|
|
.expect("presentation has a window record");
|
|
let mut visual = geometry.visual;
|
|
if self
|
|
.presentation_dependencies
|
|
.reflow_blocked(id, &self.windows)
|
|
{
|
|
let predicted_x_velocity = visual.velocity.x;
|
|
visual.velocity = Default::default();
|
|
if !geometry.is_zooming()
|
|
&& let Some((workspace, _)) = record.world_x.as_ref()
|
|
&& !self
|
|
.presentation_dependencies
|
|
.viewport_blocked(*workspace, &self.windows)
|
|
&& let Some(viewport) = self.viewports.get(workspace)
|
|
{
|
|
visual.velocity.x = if delta.is_zero() {
|
|
-viewport.presented_motion().velocity
|
|
} else {
|
|
predicted_x_velocity
|
|
};
|
|
}
|
|
}
|
|
let mut bounds = overview.presented_bounds(id, visual);
|
|
bounds.current.x += offset.0;
|
|
bounds.current.y += offset.1;
|
|
if let Some(workspace) = self.workspaces.workspace_for_window(id)
|
|
&& let Some((output_id, slide)) = self
|
|
.workspace_slides
|
|
.iter()
|
|
.find(|(_, slide)| slide.contains(workspace))
|
|
&& let Some(item) = slide.items.iter().find(|item| item.workspace == workspace)
|
|
&& let Some(area) = self
|
|
.outputs_by_id
|
|
.get(output_id)
|
|
.and_then(|output| self.space.output_geometry(output))
|
|
{
|
|
let (_, velocity) = slide.sample(item, delta);
|
|
bounds.velocity.x += velocity.x * f64::from(area.size.w);
|
|
bounds.velocity.y += velocity.y * f64::from(area.size.h);
|
|
}
|
|
let focused = if self.overview.is_active() {
|
|
self.overview_selected(id)
|
|
} else {
|
|
self.focused_window == Some(id)
|
|
};
|
|
let mut opacity = record.opening.unwrap_or_else(|| AnimatedValue::new(1.0));
|
|
let mut emphasis = record
|
|
.focus
|
|
.unwrap_or_else(|| AnimatedValue::new(if focused { 1.0 } else { 0.0 }));
|
|
let mut shadow = record.shadow.unwrap_or(emphasis);
|
|
if !self.animations_enabled {
|
|
opacity.snap();
|
|
emphasis.snap();
|
|
shadow.snap();
|
|
} else if !delta.is_zero() {
|
|
opacity.advance(
|
|
delta,
|
|
SpringConfig {
|
|
position_tolerance: 0.00001,
|
|
velocity_tolerance: 0.00001,
|
|
..self.spring_config
|
|
},
|
|
);
|
|
emphasis.advance(
|
|
delta,
|
|
crate::presentation::emphasis_spring(self.spring_config),
|
|
);
|
|
shadow.advance(
|
|
delta,
|
|
crate::presentation::shadow_spring(self.spring_config),
|
|
);
|
|
}
|
|
let presence_scale = 0.97 + 0.03 * opacity.current;
|
|
let native_size = (!overview.is_presenting() && presence_scale != 1.0)
|
|
.then(|| ClientSize::from_rect(bounds.current));
|
|
bounds.velocity = crate::presentation::scaled_visual_velocity(
|
|
bounds.current,
|
|
bounds.velocity,
|
|
presence_scale,
|
|
0.03 * opacity.velocity,
|
|
);
|
|
bounds.current = crate::presentation::scaled_visual_rect(bounds.current, presence_scale);
|
|
if let Some(rect) = self
|
|
.pointer_operation
|
|
.as_ref()
|
|
.filter(|operation| operation.id == id)
|
|
.and_then(|operation| operation.visual)
|
|
{
|
|
bounds.current = rect;
|
|
bounds.velocity = Default::default();
|
|
}
|
|
bounds.target = bounds.current;
|
|
let (focus_alpha, dim) = self.window_focus_effect(id, delta, overview.opacity());
|
|
crate::presentation::WindowPresentation {
|
|
id,
|
|
focus_alpha,
|
|
dim,
|
|
bounds,
|
|
opacity,
|
|
emphasis,
|
|
shadow,
|
|
scale_content: overview.is_presenting(),
|
|
native_size,
|
|
}
|
|
}
|
|
|
|
fn output_window_records<'a>(
|
|
&'a self,
|
|
output: &'a Output,
|
|
block_resizes: bool,
|
|
) -> impl Iterator<Item = (WindowId, &'a super::window_registry::WindowRecord, bool)> + 'a {
|
|
self.output_id(output)
|
|
.into_iter()
|
|
.flat_map(move |output| self.output_workspaces.assigned_workspaces(output))
|
|
.filter_map(move |id| self.workspaces.workspace(id))
|
|
.flat_map(move |workspace| {
|
|
workspace
|
|
.layout
|
|
.window_ids()
|
|
.chain(workspace.floating.iter().copied())
|
|
.filter_map(move |id| {
|
|
Some((
|
|
id,
|
|
self.windows.record(id)?,
|
|
block_resizes
|
|
&& self
|
|
.presentation_dependencies
|
|
.reflow_blocked(id, &self.windows),
|
|
))
|
|
})
|
|
})
|
|
.chain(
|
|
self.pointer_operation
|
|
.as_ref()
|
|
.filter(|operation| {
|
|
operation.visual.is_some()
|
|
&& self.workspaces.workspace_for_window(operation.id).and_then(
|
|
|workspace| self.output_workspaces.output_for_workspace(workspace),
|
|
) != self.output_id(output)
|
|
})
|
|
.and_then(|operation| {
|
|
self.windows
|
|
.record(operation.id)
|
|
.map(|record| (operation.id, record, false))
|
|
}),
|
|
)
|
|
}
|
|
|
|
pub(crate) fn output_has_pending_visual_changes(&self, output: &Output) -> bool {
|
|
let selected = if self.overview.is_active() {
|
|
self.overview.selected()
|
|
} else {
|
|
self.focused_window
|
|
};
|
|
self.output_window_records(output, false)
|
|
.any(|(id, record, _)| {
|
|
self.window_belongs_to_output(id, output)
|
|
&& (record.focus.as_ref().is_some_and(|focus| {
|
|
focus.needs_update(if selected == Some(id) { 1.0 } else { 0.0 })
|
|
}) || record.shadow.as_ref().is_some_and(|shadow| {
|
|
shadow.needs_update(if selected == Some(id) { 1.0 } else { 0.0 })
|
|
}) || record.focus_transition.as_ref().is_some_and(|transition| {
|
|
transition.needs_update(crate::focus_effect::target(
|
|
self.focus_effect,
|
|
self.focused_window,
|
|
id,
|
|
))
|
|
}))
|
|
})
|
|
}
|
|
|
|
pub(crate) fn output_resize_deadline(&self, output: &Output) -> Option<Duration> {
|
|
self.output_window_records(output, false)
|
|
.filter(|(id, _, _)| self.window_belongs_to_output(*id, output))
|
|
.flat_map(|(id, record, _)| {
|
|
// Client-size waits are retired while visiting mapped surfaces.
|
|
// Hidden tabs/workspaces must not arm an already-expired timer.
|
|
let mapped = self
|
|
.windows
|
|
.window(id)
|
|
.is_some_and(|window| self.space.element_location(window).is_some());
|
|
[
|
|
record.resize.map(|wait| wait.deadline()),
|
|
record
|
|
.geometry
|
|
.filter(|_| mapped)
|
|
.and_then(|geometry| geometry.client.commit_deadline()),
|
|
]
|
|
})
|
|
.flatten()
|
|
.min()
|
|
}
|
|
|
|
fn snapshot_is_waiting(&self, id: WindowId, geometry: &WindowGeometry) -> bool {
|
|
self.presentation_dependencies
|
|
.reflow_blocked(id, &self.windows)
|
|
|| geometry.client.committed_size.is_some_and(|size| {
|
|
crate::presentation::resize_needs_old_frame(
|
|
geometry.visual.current,
|
|
geometry.logical,
|
|
size.width,
|
|
size.height,
|
|
)
|
|
})
|
|
}
|
|
|
|
pub(crate) fn output_has_animations(&self, output: &Output) -> bool {
|
|
if self
|
|
.render
|
|
.closing
|
|
.iter()
|
|
.any(|window| Some(window.output) == self.output_id(output))
|
|
|| self.overview.is_animating(self.spring_config)
|
|
|| self.tab_indicator_output_animating(output)
|
|
|| self
|
|
.dismissing_popups
|
|
.iter()
|
|
.any(|(root, _, _)| self.surface_outputs(root).contains(output))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if self
|
|
.output_id(output)
|
|
.and_then(|id| self.workspace_slides.get(&id))
|
|
.is_some_and(|slide| slide.held_progress.is_none() && slide.moving())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
self.output_window_records(output, true)
|
|
.any(|(id, record, blocked)| {
|
|
let Some(mut geometry) = record.geometry else {
|
|
return false;
|
|
};
|
|
|
|
if !self.window_belongs_to_output(id, output) {
|
|
return false;
|
|
}
|
|
|
|
if record.opening.is_some()
|
|
|| (self.render.snapshot(&id).is_some()
|
|
&& !self.snapshot_is_waiting(id, &geometry))
|
|
|| record
|
|
.focus
|
|
.as_ref()
|
|
.is_some_and(|focus| focus.is_animating())
|
|
|| record
|
|
.shadow
|
|
.as_ref()
|
|
.is_some_and(|shadow| shadow.is_animating())
|
|
|| record
|
|
.focus_transition
|
|
.as_ref()
|
|
.is_some_and(|transition| transition.is_animating())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if !blocked
|
|
&& geometry.advance(Duration::ZERO, self.spring_config, self.animations_enabled)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
let Some(workspace) = self.workspaces.workspace_for_window(id) else {
|
|
return false;
|
|
};
|
|
|
|
(!self
|
|
.presentation_dependencies
|
|
.viewport_blocked(workspace, &self.windows)
|
|
&& self
|
|
.viewports
|
|
.get(&workspace)
|
|
.is_some_and(ViewportPresentation::needs_tick))
|
|
|| (!blocked
|
|
&& record.coupled_width.as_ref().is_some_and(|(_, width)| {
|
|
let mut width = *width;
|
|
width.advance(Duration::ZERO, self.viewport_spring_config)
|
|
}))
|
|
})
|
|
}
|
|
|
|
pub(crate) fn workspace_capture_visible(
|
|
&self,
|
|
workspace: ferese_core::WorkspaceId,
|
|
output: ferese_core::OutputId,
|
|
) -> bool {
|
|
self.output_workspaces.active_workspace(output) == Some(workspace)
|
|
&& !self.workspace_slides.contains_key(&output)
|
|
}
|
|
|
|
pub(crate) fn workspace_capture_stack(
|
|
&self,
|
|
id: WindowId,
|
|
geometry: WindowGeometry,
|
|
) -> (u8, usize) {
|
|
let floating = matches!(
|
|
self.workspaces.placement(id),
|
|
Some(ferese_core::WindowPlacement::Floating { .. })
|
|
);
|
|
let above = self
|
|
.floating_above_fullscreen
|
|
.get(&id)
|
|
.is_some_and(|parent| {
|
|
self.workspaces
|
|
.workspace_for_window(id)
|
|
.and_then(|workspace| self.workspaces.workspace(workspace))
|
|
.is_some_and(|workspace| workspace.fullscreen == Some(*parent))
|
|
});
|
|
(
|
|
crate::stacking::layer_priority(floating, false, geometry.is_fullscreen(), above),
|
|
self.window_stack.rank(id),
|
|
)
|
|
}
|
|
|
|
/// An independent workspace view. Global overview, output switching and
|
|
/// focus on another workspace do not enter this presentation.
|
|
pub(crate) fn sample_workspace_frame(
|
|
&self,
|
|
workspace: ferese_core::WorkspaceId,
|
|
output: &Output,
|
|
) -> FrameScene {
|
|
let rectangles = self.workspace_preview_rects_for(Some(workspace));
|
|
let visible = self.output_id(output).is_some_and(|output| {
|
|
self.output_workspaces.active_workspace(output) == Some(workspace)
|
|
});
|
|
let focused = self
|
|
.workspaces
|
|
.workspace(workspace)
|
|
.and_then(|workspace| workspace.last_focused);
|
|
let mut windows = HashMap::with_capacity(rectangles.len());
|
|
for (id, rect) in rectangles {
|
|
let Some(record) = self.windows.record(id) else {
|
|
continue;
|
|
};
|
|
let Some(committed) = self.windows.window(id).and_then(super::client_size) else {
|
|
continue;
|
|
};
|
|
let mut geometry = record
|
|
.geometry
|
|
.unwrap_or_else(|| WindowGeometry::new(rect, Some(committed)));
|
|
if !visible {
|
|
// A hidden window can have no display presentation yet. Resolve
|
|
// only this value; do not issue a client configure or map it.
|
|
geometry = WindowGeometry::new(rect, Some(committed));
|
|
let fullscreen = self
|
|
.workspaces
|
|
.workspace(workspace)
|
|
.is_some_and(|workspace| workspace.fullscreen == Some(id));
|
|
let mode = if fullscreen {
|
|
super::PresentationMode::Fullscreen
|
|
} else if record.maximized {
|
|
super::PresentationMode::Maximized
|
|
} else {
|
|
super::PresentationMode::Normal
|
|
};
|
|
let _ = geometry.set_presentation_mode(rect, mode, Duration::ZERO);
|
|
geometry.settle_presentation();
|
|
geometry.presentation_changed = false;
|
|
}
|
|
let emphasis = AnimatedValue::new(if focused == Some(id) { 1.0 } else { 0.0 });
|
|
let presentation = crate::presentation::WindowPresentation {
|
|
id,
|
|
bounds: geometry.visual,
|
|
opacity: AnimatedValue::new(1.0),
|
|
focus_alpha: 1.0,
|
|
dim: 0.0,
|
|
emphasis,
|
|
shadow: emphasis,
|
|
scale_content: false,
|
|
native_size: None,
|
|
};
|
|
windows.insert(
|
|
id,
|
|
WindowFrame {
|
|
geometry,
|
|
presentation,
|
|
},
|
|
);
|
|
}
|
|
FrameScene {
|
|
windows,
|
|
capture_tabs: Some(self.workspace_preview_tabs(workspace)),
|
|
overview: Default::default(),
|
|
animating: false,
|
|
delta: Duration::ZERO,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn sample_frame(&self, output: &Output, horizon: Duration) -> FrameScene {
|
|
let animating = self.output_has_animations(output);
|
|
let delta = if self.animations_enabled && animating {
|
|
horizon.mul_f64(self.animation_speed)
|
|
} else {
|
|
Duration::ZERO
|
|
};
|
|
|
|
let overview = self.overview.sample(delta, self.spring_config);
|
|
let predicted_slide_offsets = (!delta.is_zero())
|
|
.then(|| self.sample_workspace_slide_offsets(self.output_id(output), delta));
|
|
let slide_offsets = predicted_slide_offsets
|
|
.as_ref()
|
|
.unwrap_or(&self.workspace_slide_offsets);
|
|
let mut windows = HashMap::new();
|
|
|
|
for (id, record, blocked) in self.output_window_records(output, !delta.is_zero()) {
|
|
let Some(original) = record.geometry else {
|
|
continue;
|
|
};
|
|
|
|
// The workspace strip also draws inactive workspaces on this output.
|
|
// Keep their samples through the overview exit transition; the main
|
|
// grid still filters by window_belongs_to_output in the render path.
|
|
if !overview.is_presenting() && !self.window_belongs_to_output(id, output) {
|
|
continue;
|
|
}
|
|
|
|
let world = record.world_x.as_ref().and_then(|(workspace, world)| {
|
|
let viewport = self.viewports.get(workspace)?;
|
|
Some((
|
|
*world,
|
|
viewport.sample(
|
|
delta,
|
|
self.viewport_spring_config,
|
|
self.animations_enabled,
|
|
self.presentation_dependencies
|
|
.viewport_blocked(*workspace, &self.windows),
|
|
),
|
|
))
|
|
});
|
|
let width = record.coupled_width.as_ref().map(|(_, width)| *width);
|
|
let geometry = if delta.is_zero() {
|
|
original
|
|
} else {
|
|
predict_geometry(
|
|
original,
|
|
delta,
|
|
self.spring_config,
|
|
self.viewport_spring_config,
|
|
world,
|
|
width,
|
|
blocked,
|
|
)
|
|
};
|
|
|
|
let offset = self
|
|
.workspaces
|
|
.workspace_for_window(id)
|
|
.and_then(|workspace| slide_offsets.get(&workspace).copied())
|
|
.unwrap_or_default();
|
|
let presentation =
|
|
self.compose_window_presentation(id, geometry, &overview, offset, delta);
|
|
windows.insert(
|
|
id,
|
|
WindowFrame {
|
|
geometry,
|
|
presentation,
|
|
},
|
|
);
|
|
}
|
|
|
|
FrameScene {
|
|
windows,
|
|
capture_tabs: None,
|
|
overview,
|
|
animating,
|
|
delta,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn predict_geometry(
|
|
original: WindowGeometry,
|
|
delta: Duration,
|
|
spring: SpringConfig,
|
|
viewport_spring: SpringConfig,
|
|
world: Option<(AnimatedValue, AnimatedValue)>,
|
|
width: Option<AnimatedValue>,
|
|
reflow_held: bool,
|
|
) -> WindowGeometry {
|
|
let mut predicted = original;
|
|
let zooming = predicted.is_zooming();
|
|
if width.is_some() {
|
|
predicted.visual.target.width = predicted.visual.current.width;
|
|
predicted.visual.velocity.width = 0.0;
|
|
}
|
|
|
|
if !reflow_held {
|
|
predicted.advance(delta, spring, true);
|
|
} else {
|
|
predicted.visual.velocity = Default::default();
|
|
}
|
|
predicted.visual.target.width = original.visual.target.width;
|
|
if let Some((mut world, viewport)) = world
|
|
&& !zooming
|
|
{
|
|
if !reflow_held {
|
|
world.advance(delta, spring);
|
|
} else {
|
|
world.velocity = 0.0;
|
|
}
|
|
|
|
super::animation::sync_scrolling_coordinates(&mut predicted, &mut world, &viewport, false);
|
|
}
|
|
|
|
if let Some(mut width) = width
|
|
&& !reflow_held
|
|
{
|
|
width.advance(delta, viewport_spring);
|
|
predicted.visual.current.width = width.current;
|
|
predicted.visual.velocity.width = width.velocity;
|
|
}
|
|
|
|
predicted
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::state::SwipeDirection;
|
|
#[cfg(test)]
|
|
use calloop::EventLoop;
|
|
#[cfg(test)]
|
|
use ferese_animation::CrossingPolicy;
|
|
#[cfg(test)]
|
|
use ferese_layout::Axis;
|
|
#[cfg(test)]
|
|
use ferese_layout::Rect;
|
|
|
|
use super::*;
|
|
use crate::state::WorkspaceSlide;
|
|
use smithay::reexports::wayland_server::Display;
|
|
use std::time::Instant;
|
|
|
|
#[test]
|
|
fn pending_resize_waits_without_refresh_cadence_and_recovers() {
|
|
if !crate::startup_tests::private_runtime(
|
|
"state::prediction::tests::pending_resize_waits_without_refresh_cadence_and_recovers",
|
|
) {
|
|
return;
|
|
}
|
|
use crate::frame_scheduler::FrameScheduler;
|
|
let mut event_loop = EventLoop::try_new().unwrap();
|
|
let display = Display::new().unwrap();
|
|
let config = crate::config::Config::default().runtime_config().unwrap();
|
|
let mut state = Ferese::new(&mut event_loop, display, config).unwrap();
|
|
let output = Output::new(
|
|
"resize-idle".into(),
|
|
smithay::output::PhysicalProperties {
|
|
serial_number: String::new(),
|
|
size: (0, 0).into(),
|
|
subpixel: smithay::output::Subpixel::Unknown,
|
|
make: "test".into(),
|
|
model: "test".into(),
|
|
},
|
|
);
|
|
output.change_current_state(
|
|
Some(smithay::output::Mode {
|
|
size: (1920, 1080).into(),
|
|
refresh: 60_000,
|
|
}),
|
|
None,
|
|
None,
|
|
Some((0, 0).into()),
|
|
);
|
|
state.space.map_output(&output, (0, 0));
|
|
state.register_output(&output, "resize-idle".into());
|
|
let id = WindowId(1);
|
|
state
|
|
.workspaces
|
|
.insert_window(id, Axis::Horizontal, 0.5)
|
|
.unwrap();
|
|
let settled = WindowGeometry::new(Rect::new(0., 0., 400., 300.), None);
|
|
state.windows.records.insert(
|
|
id,
|
|
super::super::window_registry::WindowRecord {
|
|
geometry: Some(settled),
|
|
..Default::default()
|
|
},
|
|
);
|
|
state.windows.set_transaction(
|
|
id,
|
|
crate::resize_transaction::ResizeTransaction::new(9.into(), Duration::ZERO),
|
|
);
|
|
state.rebuild_presentation_dependencies();
|
|
assert_eq!(
|
|
state.output_resize_deadline(&output),
|
|
Some(Duration::from_millis(300))
|
|
);
|
|
let mut held = settled;
|
|
held.visual.set_target(Rect::new(100., 0., 400., 300.));
|
|
state.windows.set_geometry(id, held);
|
|
// Geometry which cannot advance is not visual activity either.
|
|
assert!(!state.output_has_animations(&output));
|
|
for hz in [60, 120, 240] {
|
|
let interval = Duration::from_nanos(1_000_000_000 / hz);
|
|
let mut scheduler = FrameScheduler::new(interval);
|
|
scheduler.request(Duration::ZERO);
|
|
let mut attempts = 0;
|
|
for tick in 0..(hz * 3 / 10) {
|
|
let now = interval * tick as u32;
|
|
if scheduler.plan(now).is_some() {
|
|
let plan = scheduler.begin(now).unwrap();
|
|
attempts += 1;
|
|
scheduler.rendered(plan, now, Duration::ZERO, false);
|
|
if state.sample_frame(&output, Duration::ZERO).animating {
|
|
scheduler.request(now);
|
|
}
|
|
}
|
|
}
|
|
assert_eq!(attempts, 1, "pending-only render attempts at {hz} Hz");
|
|
}
|
|
// Real fades remain live even while buffer-dependent geometry is held.
|
|
state.windows.update(id, |record| {
|
|
let mut opening = AnimatedValue::new(0.0);
|
|
opening.set_target(1.0);
|
|
record.opening = Some(opening);
|
|
});
|
|
assert!(state.output_has_animations(&output));
|
|
state.windows.update(id, |record| record.opening = None);
|
|
// A client commit removes the wait and wakes its held geometry.
|
|
let mut moving = settled;
|
|
moving.visual.set_target(Rect::new(100., 0., 400., 300.));
|
|
state.windows.set_geometry(id, moving);
|
|
state.windows.clear_transaction(&id);
|
|
state.release_presentation_dependencies();
|
|
assert_eq!(state.output_resize_deadline(&output), None);
|
|
assert!(state.output_has_animations(&output));
|
|
state
|
|
.windows
|
|
.geometry_mut(&id)
|
|
.unwrap()
|
|
.client
|
|
.request_size(
|
|
ClientSize {
|
|
width: 800,
|
|
height: 300,
|
|
},
|
|
Duration::ZERO,
|
|
);
|
|
assert_eq!(
|
|
state.output_resize_deadline(&output),
|
|
None,
|
|
"unmapped client-size waits cannot arm expired wakeups"
|
|
);
|
|
// Timeout processing still releases a pending wait.
|
|
state.windows.set_transaction(
|
|
id,
|
|
crate::resize_transaction::ResizeTransaction::new(10.into(), Duration::ZERO),
|
|
);
|
|
state.advance_animations_at(Duration::ZERO, Duration::from_millis(300));
|
|
assert!(state.windows.transaction(&id).is_none());
|
|
state.unregister_output(&output);
|
|
assert_eq!(state.output_resize_deadline(&output), None);
|
|
assert!(!state.output_has_animations(&output));
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires a private XDG_RUNTIME_DIR and permission to bind test sockets"]
|
|
fn focus_effect_sampling_preserves_activation_and_output_local_progress() {
|
|
let runtime = std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR").unwrap());
|
|
assert!(runtime.starts_with(std::env::temp_dir()));
|
|
let mut event_loop = EventLoop::try_new().unwrap();
|
|
let mut state = Ferese::new(
|
|
&mut event_loop,
|
|
Display::new().unwrap(),
|
|
crate::config::Config::default().runtime_config().unwrap(),
|
|
)
|
|
.unwrap();
|
|
state.focus_effect = crate::config::FocusEffectSettings {
|
|
enabled: true,
|
|
blur: true,
|
|
active_opacity: 0.9,
|
|
inactive_opacity: 0.6,
|
|
inactive_dim: 0.2,
|
|
duration_ms: 150.0,
|
|
};
|
|
let mut outputs = Vec::new();
|
|
for index in 0..2 {
|
|
let output = Output::new(
|
|
format!("focus-{index}"),
|
|
smithay::output::PhysicalProperties {
|
|
serial_number: String::new(),
|
|
size: (0, 0).into(),
|
|
subpixel: smithay::output::Subpixel::Unknown,
|
|
make: "test".into(),
|
|
model: "test".into(),
|
|
},
|
|
);
|
|
output.change_current_state(
|
|
Some(smithay::output::Mode {
|
|
size: (800, 600).into(),
|
|
refresh: 60_000,
|
|
}),
|
|
Some(smithay::utils::Transform::Normal),
|
|
None,
|
|
Some((index * 800, 0).into()),
|
|
);
|
|
state.space.map_output(&output, (index * 800, 0));
|
|
state.register_output(&output, format!("focus-{index}"));
|
|
let workspace = state
|
|
.output_workspaces
|
|
.active_workspace(state.output_id(&output).unwrap())
|
|
.unwrap();
|
|
let id = WindowId(index as u64 + 1);
|
|
let rect = Rect::new(index as f64 * 800.0, 0.0, 400.0, 300.0);
|
|
state
|
|
.workspaces
|
|
.insert_floating_window(id, workspace, rect, false)
|
|
.unwrap();
|
|
state.windows.records.insert(
|
|
id,
|
|
super::super::window_registry::WindowRecord {
|
|
geometry: Some(WindowGeometry::new(rect, Some(ClientSize::from_rect(rect)))),
|
|
focus_transition: Some(crate::focus_effect::BoundedFade::new(if index == 0 {
|
|
1.0
|
|
} else {
|
|
0.0
|
|
})),
|
|
..Default::default()
|
|
},
|
|
);
|
|
outputs.push(output);
|
|
}
|
|
let first = WindowId(1);
|
|
let second = WindowId(2);
|
|
state.focused_window = Some(first);
|
|
assert_eq!(
|
|
state.current_window_presentation(first).unwrap().alpha(),
|
|
0.9
|
|
);
|
|
assert_eq!(
|
|
state.current_window_presentation(second).unwrap().alpha(),
|
|
0.6
|
|
);
|
|
// Only emphasis follows overview selection; activation and its fade remain unchanged.
|
|
state.set_overview_active(true);
|
|
state.overview.select_window(second);
|
|
assert_eq!(state.focused_window, Some(first));
|
|
assert_eq!(
|
|
state.current_window_presentation(first).unwrap().alpha(),
|
|
0.9
|
|
);
|
|
assert_eq!(
|
|
state.current_window_presentation(second).unwrap().alpha(),
|
|
0.6
|
|
);
|
|
state.set_overview_active(false);
|
|
state.focused_window = Some(second);
|
|
let transition = state
|
|
.windows
|
|
.record_mut(first)
|
|
.unwrap()
|
|
.focus_transition
|
|
.as_mut()
|
|
.unwrap();
|
|
transition.advance_visual(0.0, Duration::ZERO, 150.0);
|
|
let previous = transition.current;
|
|
assert!(state.output_has_pending_visual_changes(&outputs[0]));
|
|
let frame = state.sample_frame(&outputs[0], Duration::from_millis(75));
|
|
assert!((frame.windows[&first].presentation.alpha() - 0.75).abs() < 1e-6);
|
|
assert!(!frame.windows.contains_key(&second));
|
|
assert_eq!(
|
|
state
|
|
.windows
|
|
.record(first)
|
|
.unwrap()
|
|
.focus_transition
|
|
.as_ref()
|
|
.unwrap()
|
|
.current,
|
|
previous
|
|
);
|
|
// A pending client configure must not stop focus presentation from progressing.
|
|
state.windows.set_transaction(
|
|
first,
|
|
crate::resize_transaction::ResizeTransaction::new(9.into(), Duration::ZERO),
|
|
);
|
|
let waiting = state.sample_frame(&outputs[0], Duration::from_millis(75));
|
|
assert_eq!(
|
|
waiting.windows[&first].presentation.alpha(),
|
|
frame.windows[&first].presentation.alpha()
|
|
);
|
|
assert!(state.windows.transaction(&first).is_some());
|
|
state.animations_enabled = false;
|
|
assert_eq!(
|
|
state.current_window_presentation(first).unwrap().alpha(),
|
|
0.6
|
|
);
|
|
assert_eq!(
|
|
state.current_window_presentation(second).unwrap().alpha(),
|
|
0.9
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires a private XDG_RUNTIME_DIR and permission to bind test sockets"]
|
|
fn overview_strip_switch_slides_stable_grids_and_keeps_strip_fixed() {
|
|
let runtime = std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR").unwrap());
|
|
assert!(runtime.starts_with(std::env::temp_dir()));
|
|
let mut event_loop = EventLoop::try_new().unwrap();
|
|
let mut state = Ferese::new(
|
|
&mut event_loop,
|
|
Display::new().unwrap(),
|
|
crate::config::Config::default().runtime_config().unwrap(),
|
|
)
|
|
.unwrap();
|
|
let output = Output::new(
|
|
"overview-slide-test".into(),
|
|
smithay::output::PhysicalProperties {
|
|
serial_number: String::new(),
|
|
size: (0, 0).into(),
|
|
subpixel: smithay::output::Subpixel::Unknown,
|
|
make: "test".into(),
|
|
model: "test".into(),
|
|
},
|
|
);
|
|
output.change_current_state(
|
|
Some(smithay::output::Mode {
|
|
size: (1200, 800).into(),
|
|
refresh: 120_000,
|
|
}),
|
|
Some(smithay::utils::Transform::Normal),
|
|
None,
|
|
Some((0, 0).into()),
|
|
);
|
|
state.space.map_output(&output, (0, 0));
|
|
state.register_output(&output, "overview-slide-test".into());
|
|
let output_id = state.output_id(&output).unwrap();
|
|
let from = state.output_workspaces.active_workspace(output_id).unwrap();
|
|
let to = state.workspaces.create_workspace();
|
|
state
|
|
.output_workspaces
|
|
.assign_workspace(output_id, to)
|
|
.unwrap();
|
|
for (id, workspace) in [(WindowId(1), from), (WindowId(2), to)] {
|
|
let normal = Rect::new(100., 160., 640., 480.);
|
|
state
|
|
.workspaces
|
|
.insert_floating_window(id, workspace, normal, false)
|
|
.unwrap();
|
|
state.windows.records.insert(
|
|
id,
|
|
super::super::window_registry::WindowRecord {
|
|
geometry: Some(WindowGeometry::new(normal, None)),
|
|
..Default::default()
|
|
},
|
|
);
|
|
}
|
|
state.set_overview_active(true);
|
|
state.advance_animations_by(Duration::from_secs(10));
|
|
let old = state
|
|
.current_window_presentation(WindowId(1))
|
|
.unwrap()
|
|
.bounds
|
|
.current;
|
|
let cards = state.overview_workspace_cards(&output);
|
|
let card = cards.iter().find(|card| card.workspace == to).unwrap();
|
|
assert!(
|
|
state.click_overview_workspace(
|
|
(
|
|
card.rect.x + card.rect.width * 0.5,
|
|
card.rect.y + card.rect.height * 0.5
|
|
)
|
|
.into()
|
|
)
|
|
);
|
|
assert!(state.workspace_slides.contains_key(&output_id));
|
|
assert!(state.window_belongs_to_output(WindowId(1), &output));
|
|
let incoming = state
|
|
.current_window_presentation(WindowId(2))
|
|
.unwrap()
|
|
.bounds
|
|
.current;
|
|
assert_eq!(incoming.width, old.width);
|
|
assert_eq!(incoming.height, old.height);
|
|
assert!(incoming.x > old.x + 1000.);
|
|
let predicted = state.sample_frame(&output, Duration::from_millis(60));
|
|
assert!(
|
|
predicted.windows[&WindowId(1)]
|
|
.presentation
|
|
.bounds
|
|
.current
|
|
.x
|
|
< old.x
|
|
);
|
|
assert!(
|
|
predicted.windows[&WindowId(2)]
|
|
.presentation
|
|
.bounds
|
|
.current
|
|
.x
|
|
< incoming.x
|
|
);
|
|
assert_eq!(
|
|
predicted.windows[&WindowId(2)]
|
|
.presentation
|
|
.bounds
|
|
.current
|
|
.width,
|
|
incoming.width
|
|
);
|
|
// Activation can add a trailing empty workspace. Once that layout is
|
|
// established, slide prediction must not translate the strip or previews.
|
|
let current_cards = state.overview_workspace_cards(&output);
|
|
let predicted_cards = state.overview_workspace_cards_for_frame(&output, Some(&predicted));
|
|
for (before, after) in current_cards.iter().zip(predicted_cards) {
|
|
assert_eq!(before.rect, after.rect);
|
|
assert_eq!(before.windows, after.windows);
|
|
}
|
|
state.advance_animations_by(Duration::from_millis(80));
|
|
let before = state.workspace_slides[&output_id].clone();
|
|
state.update_workspace_slide(output_id, output_id, Some(to), from, None);
|
|
let reversed = &state.workspace_slides[&output_id];
|
|
assert_eq!(reversed.speed, crate::overview::OVERVIEW_MOTION_SPEED);
|
|
for item in &before.items {
|
|
let after = reversed
|
|
.items
|
|
.iter()
|
|
.find(|other| other.workspace == item.workspace)
|
|
.unwrap();
|
|
assert_eq!(item.start, after.start);
|
|
assert_eq!(item.velocity, after.velocity);
|
|
}
|
|
// Finish on the selected workspace and retire all outgoing previews.
|
|
state
|
|
.output_workspaces
|
|
.switch_workspace(output_id, from)
|
|
.unwrap();
|
|
state.retarget_overview();
|
|
state.advance_animations_by(Duration::from_secs(10));
|
|
assert!(state.workspace_slides.is_empty());
|
|
assert!(!state.overview.has_window_preview(WindowId(2)));
|
|
assert!(!state.overview.needs_tick());
|
|
state.animations_enabled = false;
|
|
let card = state
|
|
.overview_workspace_cards(&output)
|
|
.into_iter()
|
|
.find(|card| card.workspace == to)
|
|
.unwrap();
|
|
state.click_overview_workspace(
|
|
(
|
|
card.rect.x + card.rect.width * 0.5,
|
|
card.rect.y + card.rect.height * 0.5,
|
|
)
|
|
.into(),
|
|
);
|
|
assert!(state.workspace_slides.is_empty());
|
|
assert!(!state.overview.needs_tick());
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires a private XDG_RUNTIME_DIR and permission to bind test sockets"]
|
|
fn frame_sampling_preserves_compositor_state_between_output_deadlines() {
|
|
let runtime = std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR").unwrap());
|
|
assert!(
|
|
runtime.starts_with(std::env::temp_dir()),
|
|
"use a disposable runtime directory"
|
|
);
|
|
let mut event_loop = EventLoop::try_new().unwrap();
|
|
let display = Display::new().unwrap();
|
|
let config = crate::config::Config::default().runtime_config().unwrap();
|
|
let mut state = Ferese::new(&mut event_loop, display, config).unwrap();
|
|
let output = Output::new(
|
|
"forecast-test".into(),
|
|
smithay::output::PhysicalProperties {
|
|
serial_number: String::new(),
|
|
size: (0, 0).into(),
|
|
subpixel: smithay::output::Subpixel::Unknown,
|
|
make: "test".into(),
|
|
model: "test".into(),
|
|
},
|
|
);
|
|
output.change_current_state(
|
|
Some(smithay::output::Mode {
|
|
size: (1920, 1080).into(),
|
|
refresh: 60_000,
|
|
}),
|
|
Some(smithay::utils::Transform::Normal),
|
|
None,
|
|
Some((0, 0).into()),
|
|
);
|
|
state.space.map_output(&output, (0, 0));
|
|
state.register_output(&output, "forecast-test".into());
|
|
let id = WindowId(1);
|
|
state
|
|
.workspaces
|
|
.insert_window(id, Axis::Horizontal, 0.5)
|
|
.unwrap();
|
|
let mut geometry = WindowGeometry::new(Rect::new(0., 0., 400., 300.), None);
|
|
geometry.visual.set_target(Rect::new(600., 0., 400., 300.));
|
|
// This test isolates frame sampling from the protocol client fixture.
|
|
state.windows.records.insert(
|
|
id,
|
|
super::super::window_registry::WindowRecord {
|
|
geometry: Some(geometry),
|
|
..Default::default()
|
|
},
|
|
);
|
|
let tick = state.last_animation_tick;
|
|
for horizon in [
|
|
Duration::from_millis(16),
|
|
Duration::from_millis(4),
|
|
Duration::from_millis(8),
|
|
] {
|
|
{
|
|
let forecast = state.sample_frame(&output, horizon);
|
|
assert!(
|
|
forecast.windows[&id].geometry.visual.current.x > geometry.visual.current.x
|
|
);
|
|
assert!(forecast.animating);
|
|
}
|
|
|
|
assert_eq!(*state.windows.geometry(&id).unwrap(), geometry);
|
|
assert_eq!(state.last_animation_tick, tick);
|
|
}
|
|
|
|
// Sampling one output must not visit or include another output's records.
|
|
let other = Output::new(
|
|
"other-forecast".into(),
|
|
smithay::output::PhysicalProperties {
|
|
serial_number: String::new(),
|
|
size: (0, 0).into(),
|
|
subpixel: smithay::output::Subpixel::Unknown,
|
|
make: "test".into(),
|
|
model: "test".into(),
|
|
},
|
|
);
|
|
other.change_current_state(
|
|
Some(smithay::output::Mode {
|
|
size: (1280, 720).into(),
|
|
refresh: 240_000,
|
|
}),
|
|
Some(smithay::utils::Transform::Normal),
|
|
None,
|
|
Some((1920, 0).into()),
|
|
);
|
|
state.space.map_output(&other, (1920, 0));
|
|
state.register_output(&other, "other-forecast".into());
|
|
let other_workspace = state
|
|
.output_workspaces
|
|
.active_workspace(state.output_id(&other).unwrap())
|
|
.unwrap();
|
|
let other_id = WindowId(2);
|
|
state
|
|
.workspaces
|
|
.insert_floating_window(other_id, other_workspace, geometry.visual.current, false)
|
|
.unwrap();
|
|
state.windows.records.insert(
|
|
other_id,
|
|
super::super::window_registry::WindowRecord {
|
|
geometry: Some(geometry),
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
assert_eq!(
|
|
state
|
|
.output_window_records(&output, false)
|
|
.map(|(id, _, _)| id)
|
|
.collect::<Vec<_>>(),
|
|
vec![id]
|
|
);
|
|
assert_eq!(
|
|
state
|
|
.output_window_records(&other, false)
|
|
.map(|(id, _, _)| id)
|
|
.collect::<Vec<_>>(),
|
|
vec![other_id]
|
|
);
|
|
assert!(
|
|
!state
|
|
.sample_frame(&output, Duration::from_millis(8))
|
|
.windows
|
|
.contains_key(&other_id)
|
|
);
|
|
assert!(
|
|
!state
|
|
.sample_frame(&other, Duration::from_millis(4))
|
|
.windows
|
|
.contains_key(&id)
|
|
);
|
|
|
|
let output_id = state.output_id(&output).unwrap();
|
|
let from = state.workspaces.workspace_for_window(id).unwrap();
|
|
let to = state.workspaces.create_workspace();
|
|
state
|
|
.output_workspaces
|
|
.assign_workspace(output_id, to)
|
|
.unwrap();
|
|
let mut slide = WorkspaceSlide::new(None, from, to, SwipeDirection::Left);
|
|
slide.held_progress = Some(0.25);
|
|
state.workspace_slides.insert(output_id, slide);
|
|
state.refresh_workspace_slide_offsets();
|
|
let offsets = state.workspace_slide_offsets.clone();
|
|
assert_eq!(offsets[&from].0, -480.0);
|
|
assert_eq!(offsets[&to].0, 1440.0);
|
|
assert_eq!(
|
|
state
|
|
.sample_frame(&output, Duration::from_millis(8))
|
|
.windows[&id]
|
|
.presentation
|
|
.bounds
|
|
.current
|
|
.x,
|
|
state
|
|
.sample_frame(&output, Duration::from_millis(8))
|
|
.windows[&id]
|
|
.geometry
|
|
.visual
|
|
.current
|
|
.x
|
|
- 480.0
|
|
);
|
|
assert_eq!(state.workspace_slide_offsets, offsets);
|
|
assert!(
|
|
state
|
|
.sample_workspace_slide_offsets(state.output_id(&other), Duration::from_millis(8))
|
|
.is_empty()
|
|
);
|
|
state.cancel_workspace_slides();
|
|
assert!(state.workspace_slide_offsets.is_empty());
|
|
|
|
// A prediction must neither advance nor expire a client resize barrier.
|
|
state.windows.set_transaction(
|
|
id,
|
|
crate::resize_transaction::ResizeTransaction::new(9.into(), Duration::ZERO),
|
|
);
|
|
state.rebuild_presentation_dependencies();
|
|
let blocked = state.sample_frame(&output, Duration::from_millis(16));
|
|
assert_eq!(blocked.windows[&id].geometry, geometry);
|
|
assert!(state.windows.transaction(&id).is_some());
|
|
state.windows.clear_transaction(&id);
|
|
|
|
state.animations_enabled = false;
|
|
let forecast = state.sample_frame(&output, Duration::from_millis(16));
|
|
assert_eq!(forecast.windows[&id].geometry, geometry);
|
|
|
|
let settled = WindowGeometry::new(Rect::new(0., 0., 400., 300.), None);
|
|
state.windows.set_geometry(id, settled);
|
|
state.animations_enabled = true;
|
|
assert!(!state.sample_frame(&output, Duration::ZERO).animating);
|
|
assert!(state.sample_frame(&other, Duration::ZERO).animating);
|
|
// Target changes with animations disabled still need their final frame
|
|
// on both displays, even though no spring/fade remains active afterward.
|
|
state.focused_window = Some(id);
|
|
state
|
|
.windows
|
|
.update(id, |record| record.focus = Some(AnimatedValue::new(1.0)));
|
|
state.focused_window = Some(other_id);
|
|
assert!(state.output_has_pending_visual_changes(&output));
|
|
state.animations_enabled = false;
|
|
state.advance_animations(Instant::now());
|
|
assert!(!state.output_has_pending_visual_changes(&output));
|
|
state.animations_enabled = true;
|
|
state.windows.set_geometry(id, geometry);
|
|
assert!(state.output_has_animations(&output));
|
|
state.unregister_output(&other);
|
|
assert!(!state.output_has_animations(&other));
|
|
assert!(
|
|
state.output_has_animations(&output),
|
|
"remaining output keeps its active animation"
|
|
);
|
|
|
|
let inactive = state.workspaces.create_workspace();
|
|
state
|
|
.output_workspaces
|
|
.assign_workspace(output_id, inactive)
|
|
.unwrap();
|
|
let preview_id = WindowId(3);
|
|
state
|
|
.workspaces
|
|
.insert_floating_window(preview_id, inactive, settled.visual.current, false)
|
|
.unwrap();
|
|
state.windows.records.insert(
|
|
preview_id,
|
|
super::super::window_registry::WindowRecord {
|
|
geometry: Some(settled),
|
|
..Default::default()
|
|
},
|
|
);
|
|
assert!(
|
|
!state
|
|
.sample_frame(&output, Duration::ZERO)
|
|
.windows
|
|
.contains_key(&preview_id)
|
|
);
|
|
|
|
state.set_overview_active(true);
|
|
let frame = state.sample_frame(&output, Duration::ZERO);
|
|
assert!(
|
|
frame.windows.contains_key(&preview_id),
|
|
"inactive workspace needs a thumbnail sample"
|
|
);
|
|
assert!(
|
|
!state.window_belongs_to_output(preview_id, &output),
|
|
"thumbnail must stay out of the main grid"
|
|
);
|
|
for card in state.overview_workspace_cards_for_frame(&output, Some(&frame)) {
|
|
for (id, _) in card.windows {
|
|
assert!(
|
|
frame.windows.contains_key(&id),
|
|
"workspace card has no presentation for {id:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
state.set_overview_active(false);
|
|
assert!(
|
|
state.overview.is_presenting(),
|
|
"exit transition keeps the strip visible"
|
|
);
|
|
assert!(
|
|
state
|
|
.sample_frame(&output, Duration::ZERO)
|
|
.windows
|
|
.contains_key(&preview_id)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_refresh_forecasts_do_not_change_authoritative_geometry_or_client_state() {
|
|
let mut geometry = WindowGeometry::new(Rect::new(0., 0., 400., 300.), None);
|
|
geometry.visual.set_target(Rect::new(600., 0., 500., 300.));
|
|
let original = geometry;
|
|
let sample = |delta| {
|
|
predict_geometry(
|
|
geometry,
|
|
delta,
|
|
SpringConfig::default(),
|
|
SpringConfig::default(),
|
|
None,
|
|
None,
|
|
false,
|
|
)
|
|
};
|
|
|
|
let fast = sample(Duration::from_nanos(1_000_000_000 / 240));
|
|
let slow = sample(Duration::from_nanos(1_000_000_000 / 60));
|
|
assert!(fast.visual.current.x > 0.);
|
|
assert!(slow.visual.current.x > fast.visual.current.x);
|
|
assert_eq!(sample(Duration::from_nanos(1_000_000_000 / 240)), fast);
|
|
assert_eq!(geometry, original);
|
|
assert_eq!(slow.client, original.client);
|
|
assert_eq!(slow.logical, original.logical);
|
|
}
|
|
|
|
#[test]
|
|
fn scrolling_coordinates_and_coupled_width_use_the_same_forecast_time() {
|
|
let mut geometry = WindowGeometry::new(Rect::new(500., 0., 400., 300.), None);
|
|
geometry.set_logical_target(Rect::new(400., 0., 500., 300.), Duration::ZERO);
|
|
let mut world = AnimatedValue::new(500.);
|
|
world.set_target(600.);
|
|
let mut viewport = AnimatedValue::new(0.);
|
|
viewport.set_target(200.);
|
|
let mut width = AnimatedValue::new(400.);
|
|
width.set_target(500.);
|
|
let spring = SpringConfig::default();
|
|
let delta = Duration::from_millis(10);
|
|
let sampled_viewport =
|
|
ViewportPresentation::from_motion(viewport).sample(delta, spring, true, false);
|
|
let predicted = predict_geometry(
|
|
geometry,
|
|
delta,
|
|
spring,
|
|
spring,
|
|
Some((world, sampled_viewport)),
|
|
Some(width),
|
|
false,
|
|
);
|
|
world.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
|
|
viewport.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
|
|
width.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
|
|
assert_eq!(predicted.visual.current.x, world.current - viewport.current);
|
|
assert_eq!(predicted.visual.current.width, width.current);
|
|
assert_eq!(predicted.visual.target, geometry.visual.target);
|
|
}
|
|
}
|