feat: animate scrolling workspace viewports
This commit is contained in:
1 parent
f0862348c9
commit
ed529ae900
9 files changed
+623
-52
No files matched your search
@@ -37,6 +37,11 @@ default_column_width = "full"
|
|||||||
`default_column_width = 1.0` is equivalent. `Super+M` switches the active
|
`default_column_width = 1.0` is equivalent. `Super+M` switches the active
|
||||||
workspace between scrolling and tree modes.
|
workspace between scrolling and tree modes.
|
||||||
|
|
||||||
|
Scrolling keeps windows in stable workspace coordinates and animates the
|
||||||
|
viewport only when focus must be revealed. Set
|
||||||
|
`focus_strategy = "center_on_focus"` under `[scrolling]` to center focused
|
||||||
|
columns instead.
|
||||||
|
|
||||||
Scrolling controls use `Super+R` to cycle configured width presets, `Super+C`
|
Scrolling controls use `Super+R` to cycle configured width presets, `Super+C`
|
||||||
to center the focused column, `Super+[` to consume the focused window into an
|
to center the focused column, `Super+[` to consume the focused window into an
|
||||||
adjacent column, and `Super+]` to expel it. `Super+Ctrl+H/J/K/L` resizes in the
|
adjacent column, and `Super+]` to expel it. `Super+Ctrl+H/J/K/L` resizes in the
|
||||||
|
|||||||
+100
-1
@@ -7,7 +7,7 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use ferese_animation::SpringConfig;
|
use ferese_animation::SpringConfig;
|
||||||
use ferese_core::LayoutMode;
|
use ferese_core::LayoutMode;
|
||||||
use ferese_layout::ColumnWidth;
|
use ferese_layout::{ColumnWidth, ViewportFocusStrategy};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
@@ -30,6 +30,8 @@ struct AnimationsConfig {
|
|||||||
speed: f64,
|
speed: f64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
spring: SpringSettings,
|
spring: SpringSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
viewport_spring: ViewportSpringSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AnimationsConfig {
|
impl Default for AnimationsConfig {
|
||||||
@@ -39,6 +41,27 @@ impl Default for AnimationsConfig {
|
|||||||
reduced_motion: false,
|
reduced_motion: false,
|
||||||
speed: 1.0,
|
speed: 1.0,
|
||||||
spring: SpringSettings::default(),
|
spring: SpringSettings::default(),
|
||||||
|
viewport_spring: ViewportSpringSettings::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ViewportSpringSettings {
|
||||||
|
#[serde(default = "default_viewport_mass")]
|
||||||
|
mass: f64,
|
||||||
|
#[serde(default = "default_viewport_stiffness")]
|
||||||
|
stiffness: f64,
|
||||||
|
#[serde(default = "default_viewport_damping_ratio")]
|
||||||
|
damping_ratio: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ViewportSpringSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mass: default_viewport_mass(),
|
||||||
|
stiffness: default_viewport_stiffness(),
|
||||||
|
damping_ratio: default_viewport_damping_ratio(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,10 +101,18 @@ enum LayoutModeValue {
|
|||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
struct ScrollingConfig {
|
struct ScrollingConfig {
|
||||||
default_column_width: Option<ColumnWidthValue>,
|
default_column_width: Option<ColumnWidthValue>,
|
||||||
|
focus_strategy: Option<FocusStrategyValue>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
width_presets: Vec<ColumnWidthValue>,
|
width_presets: Vec<ColumnWidthValue>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
enum FocusStrategyValue {
|
||||||
|
Minimal,
|
||||||
|
CenterOnFocus,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
enum ColumnWidthValue {
|
enum ColumnWidthValue {
|
||||||
@@ -187,6 +218,17 @@ impl Config {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn scrolling_focus_strategy(&self) -> ViewportFocusStrategy {
|
||||||
|
match self
|
||||||
|
.scrolling
|
||||||
|
.focus_strategy
|
||||||
|
.unwrap_or(FocusStrategyValue::Minimal)
|
||||||
|
{
|
||||||
|
FocusStrategyValue::Minimal => ViewportFocusStrategy::Minimal,
|
||||||
|
FocusStrategyValue::CenterOnFocus => ViewportFocusStrategy::Center,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn animations_enabled(&self) -> bool {
|
pub fn animations_enabled(&self) -> bool {
|
||||||
self.animations.enabled && !self.animations.reduced_motion
|
self.animations.enabled && !self.animations.reduced_motion
|
||||||
}
|
}
|
||||||
@@ -214,6 +256,26 @@ impl Config {
|
|||||||
..SpringConfig::default()
|
..SpringConfig::default()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn viewport_spring_config(&self) -> Result<SpringConfig, ConfigError> {
|
||||||
|
let mass =
|
||||||
|
positive_animation_value(self.animations.viewport_spring.mass, "viewport_spring.mass")?;
|
||||||
|
let stiffness = positive_animation_value(
|
||||||
|
self.animations.viewport_spring.stiffness,
|
||||||
|
"viewport_spring.stiffness",
|
||||||
|
)?;
|
||||||
|
let damping_ratio = positive_animation_value(
|
||||||
|
self.animations.viewport_spring.damping_ratio,
|
||||||
|
"viewport_spring.damping_ratio",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(SpringConfig {
|
||||||
|
mass,
|
||||||
|
stiffness,
|
||||||
|
damping: 2.0 * damping_ratio * (stiffness * mass).sqrt(),
|
||||||
|
..SpringConfig::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn positive_animation_value(value: f64, field: &'static str) -> Result<f64, ConfigError> {
|
fn positive_animation_value(value: f64, field: &'static str) -> Result<f64, ConfigError> {
|
||||||
@@ -244,6 +306,18 @@ const fn default_spring_damping() -> f64 {
|
|||||||
53.0
|
53.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fn default_viewport_mass() -> f64 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_viewport_stiffness() -> f64 {
|
||||||
|
320.0
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_viewport_damping_ratio() -> f64 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_column_width(
|
fn parse_column_width(
|
||||||
value: &ColumnWidthValue,
|
value: &ColumnWidthValue,
|
||||||
field: &'static str,
|
field: &'static str,
|
||||||
@@ -323,6 +397,21 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_scrolling_focus_strategy() {
|
||||||
|
let minimal = parse("");
|
||||||
|
let centered = parse("[scrolling]\nfocus_strategy = \"center_on_focus\"");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
minimal.scrolling_focus_strategy(),
|
||||||
|
ViewportFocusStrategy::Minimal
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
centered.scrolling_focus_strategy(),
|
||||||
|
ViewportFocusStrategy::Center
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_non_positive_or_unknown_column_widths() {
|
fn rejects_non_positive_or_unknown_column_widths() {
|
||||||
let zero = parse("[scrolling]\ndefault_column_width = 0.0");
|
let zero = parse("[scrolling]\ndefault_column_width = 0.0");
|
||||||
@@ -359,4 +448,14 @@ mod tests {
|
|||||||
assert!(speed.animation_speed().is_err());
|
assert!(speed.animation_speed().is_err());
|
||||||
assert!(damping.spring_config().is_err());
|
assert!(damping.spring_config().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derives_critical_viewport_damping() {
|
||||||
|
let config = parse("");
|
||||||
|
let spring = config.viewport_spring_config().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(spring.mass, 1.0);
|
||||||
|
assert_eq!(spring.stiffness, 320.0);
|
||||||
|
assert!((spring.damping - 35.777_087_64).abs() < 0.000_001);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -29,10 +29,12 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
let runtime = RuntimeConfig {
|
let runtime = RuntimeConfig {
|
||||||
layout_mode: config.layout_mode(),
|
layout_mode: config.layout_mode(),
|
||||||
default_column_width: config.default_column_width()?,
|
default_column_width: config.default_column_width()?,
|
||||||
|
scrolling_focus_strategy: config.scrolling_focus_strategy(),
|
||||||
column_width_presets: config.width_presets()?,
|
column_width_presets: config.width_presets()?,
|
||||||
animations_enabled: config.animations_enabled(),
|
animations_enabled: config.animations_enabled(),
|
||||||
animation_speed: config.animation_speed()?,
|
animation_speed: config.animation_speed()?,
|
||||||
spring_config: config.spring_config()?,
|
spring_config: config.spring_config()?,
|
||||||
|
viewport_spring_config: config.viewport_spring_config()?,
|
||||||
};
|
};
|
||||||
let mut state = Ferese::new(&mut event_loop, display, runtime)?;
|
let mut state = Ferese::new(&mut event_loop, display, runtime)?;
|
||||||
backends::init(launch.backend, &mut event_loop, &mut state)?;
|
backends::init(launch.backend, &mut event_loop, &mut state)?;
|
||||||
|
|||||||
+94
-6
@@ -6,12 +6,14 @@ use std::{
|
|||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use ferese_animation::{ClientSize, SpringConfig, WindowGeometry};
|
use ferese_animation::{AnimatedValue, ClientSize, SpringConfig, WindowGeometry};
|
||||||
use ferese_core::{
|
use ferese_core::{
|
||||||
LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceSet,
|
LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceId,
|
||||||
|
WorkspaceSet,
|
||||||
};
|
};
|
||||||
use ferese_layout::{
|
use ferese_layout::{
|
||||||
Axis, ColumnWidth, Direction, GapConfig, LayoutResult, Rect, SizeConstraints, WindowId,
|
Axis, ColumnWidth, Direction, GapConfig, LayoutResult, Rect, SizeConstraints,
|
||||||
|
ViewportFocusStrategy, WindowId,
|
||||||
};
|
};
|
||||||
|
|
||||||
use smithay::{
|
use smithay::{
|
||||||
@@ -84,11 +86,14 @@ pub struct Ferese {
|
|||||||
output_identity_ids: HashMap<String, OutputId>,
|
output_identity_ids: HashMap<String, OutputId>,
|
||||||
pub window_ids: HashMap<Window, WindowId>,
|
pub window_ids: HashMap<Window, WindowId>,
|
||||||
pub window_geometry: HashMap<WindowId, WindowGeometry>,
|
pub window_geometry: HashMap<WindowId, WindowGeometry>,
|
||||||
|
viewport_animations: HashMap<WorkspaceId, AnimatedValue>,
|
||||||
|
scrolling_world_x: HashMap<WindowId, (WorkspaceId, AnimatedValue)>,
|
||||||
pub focused_window: Option<WindowId>,
|
pub focused_window: Option<WindowId>,
|
||||||
column_width_presets: Vec<ColumnWidth>,
|
column_width_presets: Vec<ColumnWidth>,
|
||||||
animations_enabled: bool,
|
animations_enabled: bool,
|
||||||
animation_speed: f64,
|
animation_speed: f64,
|
||||||
spring_config: SpringConfig,
|
spring_config: SpringConfig,
|
||||||
|
viewport_spring_config: SpringConfig,
|
||||||
pub cursor_status: CursorImageStatus,
|
pub cursor_status: CursorImageStatus,
|
||||||
pub(crate) cursor_theme: xcursor::CursorTheme,
|
pub(crate) cursor_theme: xcursor::CursorTheme,
|
||||||
pub(crate) named_cursors: HashMap<CursorIcon, crate::cursor::NamedCursor>,
|
pub(crate) named_cursors: HashMap<CursorIcon, crate::cursor::NamedCursor>,
|
||||||
@@ -133,10 +138,12 @@ pub struct Ferese {
|
|||||||
pub struct RuntimeConfig {
|
pub struct RuntimeConfig {
|
||||||
pub layout_mode: LayoutMode,
|
pub layout_mode: LayoutMode,
|
||||||
pub default_column_width: ColumnWidth,
|
pub default_column_width: ColumnWidth,
|
||||||
|
pub scrolling_focus_strategy: ViewportFocusStrategy,
|
||||||
pub column_width_presets: Vec<ColumnWidth>,
|
pub column_width_presets: Vec<ColumnWidth>,
|
||||||
pub animations_enabled: bool,
|
pub animations_enabled: bool,
|
||||||
pub animation_speed: f64,
|
pub animation_speed: f64,
|
||||||
pub spring_config: SpringConfig,
|
pub spring_config: SpringConfig,
|
||||||
|
pub viewport_spring_config: SpringConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ferese {
|
impl Ferese {
|
||||||
@@ -342,17 +349,24 @@ impl Ferese {
|
|||||||
display_handle,
|
display_handle,
|
||||||
loop_signal: event_loop.get_signal(),
|
loop_signal: event_loop.get_signal(),
|
||||||
space: Space::default(),
|
space: Space::default(),
|
||||||
workspaces: WorkspaceSet::new(config.layout_mode, config.default_column_width),
|
workspaces: WorkspaceSet::new(
|
||||||
|
config.layout_mode,
|
||||||
|
config.default_column_width,
|
||||||
|
config.scrolling_focus_strategy,
|
||||||
|
),
|
||||||
output_workspaces: OutputWorkspaceMap::default(),
|
output_workspaces: OutputWorkspaceMap::default(),
|
||||||
output_ids: HashMap::new(),
|
output_ids: HashMap::new(),
|
||||||
output_identity_ids: HashMap::new(),
|
output_identity_ids: HashMap::new(),
|
||||||
window_ids: HashMap::new(),
|
window_ids: HashMap::new(),
|
||||||
window_geometry: HashMap::new(),
|
window_geometry: HashMap::new(),
|
||||||
|
viewport_animations: HashMap::new(),
|
||||||
|
scrolling_world_x: HashMap::new(),
|
||||||
focused_window: None,
|
focused_window: None,
|
||||||
column_width_presets: config.column_width_presets,
|
column_width_presets: config.column_width_presets,
|
||||||
animations_enabled: config.animations_enabled,
|
animations_enabled: config.animations_enabled,
|
||||||
animation_speed: config.animation_speed,
|
animation_speed: config.animation_speed,
|
||||||
spring_config: config.spring_config,
|
spring_config: config.spring_config,
|
||||||
|
viewport_spring_config: config.viewport_spring_config,
|
||||||
cursor_status: CursorImageStatus::default_named(),
|
cursor_status: CursorImageStatus::default_named(),
|
||||||
cursor_theme,
|
cursor_theme,
|
||||||
named_cursors,
|
named_cursors,
|
||||||
@@ -639,6 +653,7 @@ impl Ferese {
|
|||||||
|
|
||||||
self.space.unmap_elem(window);
|
self.space.unmap_elem(window);
|
||||||
self.window_geometry.remove(&id);
|
self.window_geometry.remove(&id);
|
||||||
|
self.scrolling_world_x.remove(&id);
|
||||||
if let Err(error) = self.workspaces.remove_window(id) {
|
if let Err(error) = self.workspaces.remove_window(id) {
|
||||||
tracing::error!(%error, ?id, "failed to remove window from layout");
|
tracing::error!(%error, ?id, "failed to remove window from layout");
|
||||||
}
|
}
|
||||||
@@ -651,6 +666,7 @@ impl Ferese {
|
|||||||
|
|
||||||
pub fn relayout(&mut self) {
|
pub fn relayout(&mut self) {
|
||||||
self.arrange_layers();
|
self.arrange_layers();
|
||||||
|
let mut previous_scrolling_world_x = std::mem::take(&mut self.scrolling_world_x);
|
||||||
let outputs = self.space.outputs().cloned().collect::<Vec<_>>();
|
let outputs = self.space.outputs().cloned().collect::<Vec<_>>();
|
||||||
let constraints = self.window_constraints();
|
let constraints = self.window_constraints();
|
||||||
let mut visible = HashSet::new();
|
let mut visible = HashSet::new();
|
||||||
@@ -693,6 +709,18 @@ impl Ferese {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let viewport_target = workspace.layout.viewport_x();
|
||||||
|
let viewport_current = viewport_target.map(|target| {
|
||||||
|
let viewport = self
|
||||||
|
.viewport_animations
|
||||||
|
.entry(workspace_id)
|
||||||
|
.or_insert_with(|| AnimatedValue::new(target));
|
||||||
|
viewport.retarget_preserving_motion(target);
|
||||||
|
if !self.animations_enabled {
|
||||||
|
viewport.snap();
|
||||||
|
}
|
||||||
|
viewport.current
|
||||||
|
});
|
||||||
|
|
||||||
for warning in layout.warnings {
|
for warning in layout.warnings {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -732,7 +760,22 @@ impl Ferese {
|
|||||||
);
|
);
|
||||||
|
|
||||||
visible.insert(*id);
|
visible.insert(*id);
|
||||||
placements.push((window.clone(), *id, rect, is_fullscreen, is_floating));
|
let scrolling = if !is_fullscreen && !is_floating {
|
||||||
|
viewport_target
|
||||||
|
.zip(viewport_current)
|
||||||
|
.map(|(target, current)| (workspace_id, rect.x + target, current))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
placements.push((
|
||||||
|
window.clone(),
|
||||||
|
*id,
|
||||||
|
rect,
|
||||||
|
is_fullscreen,
|
||||||
|
is_floating,
|
||||||
|
scrolling,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,7 +792,9 @@ impl Ferese {
|
|||||||
|
|
||||||
let now = self.start_time.elapsed();
|
let now = self.start_time.elapsed();
|
||||||
|
|
||||||
for (window, id, rect, is_fullscreen, is_floating) in placements {
|
let mut scrolling_world_x = HashMap::new();
|
||||||
|
|
||||||
|
for (window, id, rect, is_fullscreen, is_floating, scrolling) in placements {
|
||||||
let committed_size = client_size(&window);
|
let committed_size = client_size(&window);
|
||||||
let geometry = self
|
let geometry = self
|
||||||
.window_geometry
|
.window_geometry
|
||||||
@@ -759,6 +804,20 @@ impl Ferese {
|
|||||||
if !self.animations_enabled {
|
if !self.animations_enabled {
|
||||||
geometry.advance(Duration::ZERO, self.spring_config, false);
|
geometry.advance(Duration::ZERO, self.spring_config, false);
|
||||||
}
|
}
|
||||||
|
if let Some((workspace, world_x, viewport_x)) = scrolling {
|
||||||
|
let mut animated_world_x = previous_scrolling_world_x
|
||||||
|
.remove(&id)
|
||||||
|
.filter(|(previous_workspace, _)| *previous_workspace == workspace)
|
||||||
|
.map(|(_, world_x)| world_x)
|
||||||
|
.unwrap_or_else(|| AnimatedValue::new(world_x));
|
||||||
|
animated_world_x.set_target(world_x);
|
||||||
|
if !self.animations_enabled {
|
||||||
|
animated_world_x.snap();
|
||||||
|
}
|
||||||
|
geometry.visual.current.x = animated_world_x.current - viewport_x;
|
||||||
|
geometry.visual.velocity.x = 0.0;
|
||||||
|
scrolling_world_x.insert(id, (workspace, animated_world_x));
|
||||||
|
}
|
||||||
let visual = geometry.visual.current;
|
let visual = geometry.visual.current;
|
||||||
let location = (visual.x.round() as i32, visual.y.round() as i32);
|
let location = (visual.x.round() as i32, visual.y.round() as i32);
|
||||||
|
|
||||||
@@ -804,6 +863,7 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.scrolling_world_x = scrolling_world_x;
|
||||||
|
|
||||||
crate::backends::direct::render_all(self);
|
crate::backends::direct::render_all(self);
|
||||||
}
|
}
|
||||||
@@ -832,6 +892,12 @@ impl Ferese {
|
|||||||
|
|
||||||
fn advance_animations_by(&mut self, delta: std::time::Duration) -> bool {
|
fn advance_animations_by(&mut self, delta: std::time::Duration) -> bool {
|
||||||
let delta = delta.mul_f64(self.animation_speed);
|
let delta = delta.mul_f64(self.animation_speed);
|
||||||
|
let visible_workspaces = self
|
||||||
|
.space
|
||||||
|
.outputs()
|
||||||
|
.filter_map(|output| self.output_ids.get(output))
|
||||||
|
.filter_map(|output| self.output_workspaces.active_workspace(*output))
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
let windows = self
|
let windows = self
|
||||||
.space
|
.space
|
||||||
.elements()
|
.elements()
|
||||||
@@ -844,6 +910,17 @@ impl Ferese {
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mut active_animation = false;
|
let mut active_animation = false;
|
||||||
|
|
||||||
|
for (workspace, viewport) in &mut self.viewport_animations {
|
||||||
|
if !visible_workspaces.contains(workspace) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.animations_enabled {
|
||||||
|
active_animation |= viewport.advance(delta, self.viewport_spring_config);
|
||||||
|
} else {
|
||||||
|
viewport.snap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (window, id) in windows {
|
for (window, id) in windows {
|
||||||
let Some(geometry) = self.window_geometry.get_mut(&id) else {
|
let Some(geometry) = self.window_geometry.get_mut(&id) else {
|
||||||
continue;
|
continue;
|
||||||
@@ -851,6 +928,17 @@ impl Ferese {
|
|||||||
|
|
||||||
active_animation |=
|
active_animation |=
|
||||||
geometry.advance(delta, self.spring_config, self.animations_enabled);
|
geometry.advance(delta, self.spring_config, self.animations_enabled);
|
||||||
|
if let Some((workspace, world_x)) = self.scrolling_world_x.get_mut(&id)
|
||||||
|
&& let Some(viewport) = self.viewport_animations.get(workspace)
|
||||||
|
{
|
||||||
|
if self.animations_enabled {
|
||||||
|
active_animation |= world_x.advance(delta, self.spring_config);
|
||||||
|
} else {
|
||||||
|
world_x.snap();
|
||||||
|
}
|
||||||
|
geometry.visual.current.x = world_x.current - viewport.current;
|
||||||
|
geometry.visual.velocity.x = 0.0;
|
||||||
|
}
|
||||||
if let Some(size) = geometry.client.expire_wait(self.start_time.elapsed()) {
|
if let Some(size) = geometry.client.expire_wait(self.start_time.elapsed()) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
?id,
|
?id,
|
||||||
|
|||||||
@@ -56,6 +56,83 @@ pub struct AnimatedRect {
|
|||||||
pub velocity: RectVelocity,
|
pub velocity: RectVelocity,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct AnimatedValue {
|
||||||
|
pub current: f64,
|
||||||
|
pub target: f64,
|
||||||
|
pub velocity: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnimatedValue {
|
||||||
|
pub fn new(value: f64) -> Self {
|
||||||
|
let value = finite_or_zero(value);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
current: value,
|
||||||
|
target: value,
|
||||||
|
velocity: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_target(&mut self, target: f64) {
|
||||||
|
self.target = finite_or_zero(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retarget_preserving_motion(&mut self, target: f64) {
|
||||||
|
let target = finite_or_zero(target);
|
||||||
|
let direction = (target - self.current).signum();
|
||||||
|
|
||||||
|
if direction != 0.0 && self.velocity.signum() != direction {
|
||||||
|
self.velocity *= 0.35;
|
||||||
|
}
|
||||||
|
self.target = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snap(&mut self) {
|
||||||
|
self.current = self.target;
|
||||||
|
self.velocity = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn advance(&mut self, delta: Duration, config: SpringConfig) -> bool {
|
||||||
|
let config = config.normalized();
|
||||||
|
if (self.current - self.target).abs() <= config.position_tolerance
|
||||||
|
&& self.velocity.abs() <= config.velocity_tolerance
|
||||||
|
{
|
||||||
|
self.snap();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let seconds = delta.min(MAX_FRAME_DELTA).as_secs_f64();
|
||||||
|
let steps = (seconds / MAX_STEP_SECONDS).ceil().max(1.0) as usize;
|
||||||
|
let step = seconds / steps as f64;
|
||||||
|
|
||||||
|
for _ in 0..steps {
|
||||||
|
let previous_error = self.current - self.target;
|
||||||
|
advance_value(
|
||||||
|
&mut self.current,
|
||||||
|
self.target,
|
||||||
|
&mut self.velocity,
|
||||||
|
step,
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
let current_error = self.current - self.target;
|
||||||
|
if previous_error != 0.0 && previous_error.signum() != current_error.signum() {
|
||||||
|
self.snap();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.current - self.target).abs() <= config.position_tolerance
|
||||||
|
&& self.velocity.abs() <= config.velocity_tolerance
|
||||||
|
{
|
||||||
|
self.snap();
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl AnimatedRect {
|
impl AnimatedRect {
|
||||||
pub fn new(rect: Rect) -> Self {
|
pub fn new(rect: Rect) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -276,6 +353,10 @@ fn advance_value(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn finite_or_zero(value: f64) -> f64 {
|
||||||
|
if value.is_finite() { value } else { 0.0 }
|
||||||
|
}
|
||||||
|
|
||||||
fn normalized_rect(rect: Rect) -> Rect {
|
fn normalized_rect(rect: Rect) -> Rect {
|
||||||
Rect::new(
|
Rect::new(
|
||||||
finite_or(rect.x, 0.0),
|
finite_or(rect.x, 0.0),
|
||||||
@@ -326,6 +407,48 @@ fn finite_or(value: f64, fallback: f64) -> f64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scalar_spring_retargets_from_current_motion() {
|
||||||
|
let mut value = AnimatedValue::new(0.0);
|
||||||
|
value.set_target(1_000.0);
|
||||||
|
value.advance(Duration::from_millis(40), SpringConfig::default());
|
||||||
|
let current = value.current;
|
||||||
|
let velocity = value.velocity;
|
||||||
|
|
||||||
|
value.retarget_preserving_motion(2_000.0);
|
||||||
|
|
||||||
|
assert_eq!(value.current, current);
|
||||||
|
assert_eq!(value.velocity, velocity);
|
||||||
|
assert_eq!(value.target, 2_000.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scalar_spring_softens_opposing_velocity_on_reversal() {
|
||||||
|
let mut value = AnimatedValue::new(0.0);
|
||||||
|
value.set_target(1_000.0);
|
||||||
|
value.advance(Duration::from_millis(40), SpringConfig::default());
|
||||||
|
let current = value.current;
|
||||||
|
let velocity = value.velocity;
|
||||||
|
|
||||||
|
value.retarget_preserving_motion(-500.0);
|
||||||
|
|
||||||
|
assert!(value.velocity > 0.0);
|
||||||
|
assert_eq!(value.velocity, velocity * 0.35);
|
||||||
|
assert_eq!(value.current, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scalar_spring_does_not_overshoot_target() {
|
||||||
|
let mut value = AnimatedValue::new(0.0);
|
||||||
|
value.velocity = 20_000.0;
|
||||||
|
value.set_target(100.0);
|
||||||
|
|
||||||
|
for _ in 0..60 {
|
||||||
|
value.advance(Duration::from_secs_f64(1.0 / 60.0), SpringConfig::default());
|
||||||
|
assert!(value.current <= 100.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn logical_target_changes_without_jumping_visual_geometry() {
|
fn logical_target_changes_without_jumping_visual_geometry() {
|
||||||
let initial = Rect::new(0.0, 0.0, 400.0, 300.0);
|
let initial = Rect::new(0.0, 0.0, 400.0, 300.0);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::fmt;
|
|||||||
|
|
||||||
use ferese_layout::{
|
use ferese_layout::{
|
||||||
Axis, ColumnWidth, Direction, GapConfig, LayoutError, LayoutResult, LayoutTree, Rect,
|
Axis, ColumnWidth, Direction, GapConfig, LayoutError, LayoutResult, LayoutTree, Rect,
|
||||||
ScrollingLayout, SizeConstraints, WindowId,
|
ScrollingLayout, SizeConstraints, ViewportFocusStrategy, WindowId,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
@@ -40,10 +40,16 @@ impl Default for WorkspaceLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceLayout {
|
impl WorkspaceLayout {
|
||||||
pub fn new(mode: LayoutMode, default_column_width: ColumnWidth) -> Self {
|
pub fn new(
|
||||||
|
mode: LayoutMode,
|
||||||
|
default_column_width: ColumnWidth,
|
||||||
|
focus_strategy: ViewportFocusStrategy,
|
||||||
|
) -> Self {
|
||||||
match mode {
|
match mode {
|
||||||
LayoutMode::Scrolling => {
|
LayoutMode::Scrolling => {
|
||||||
Self::Scrolling(ScrollingLayout::with_default_width(default_column_width))
|
let mut layout = ScrollingLayout::with_default_width(default_column_width);
|
||||||
|
layout.set_focus_strategy(focus_strategy);
|
||||||
|
Self::Scrolling(layout)
|
||||||
}
|
}
|
||||||
LayoutMode::Tree => Self::Tree(LayoutTree::default()),
|
LayoutMode::Tree => Self::Tree(LayoutTree::default()),
|
||||||
}
|
}
|
||||||
@@ -56,6 +62,20 @@ impl WorkspaceLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn viewport_x(&self) -> Option<f64> {
|
||||||
|
match self {
|
||||||
|
Self::Scrolling(layout) => Some(layout.viewport_x()),
|
||||||
|
Self::Tree(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preferred_window(&self) -> Option<WindowId> {
|
||||||
|
match self {
|
||||||
|
Self::Scrolling(layout) => layout.active_window(),
|
||||||
|
Self::Tree(layout) => layout.window_ids().next(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn contains(&self, window: WindowId) -> bool {
|
pub fn contains(&self, window: WindowId) -> bool {
|
||||||
match self {
|
match self {
|
||||||
Self::Scrolling(layout) => layout.contains(window),
|
Self::Scrolling(layout) => layout.contains(window),
|
||||||
@@ -235,6 +255,7 @@ impl WorkspaceLayout {
|
|||||||
bounds: Rect,
|
bounds: Rect,
|
||||||
focused: Option<WindowId>,
|
focused: Option<WindowId>,
|
||||||
default_column_width: ColumnWidth,
|
default_column_width: ColumnWidth,
|
||||||
|
focus_strategy: ViewportFocusStrategy,
|
||||||
) -> Result<bool, LayoutError> {
|
) -> Result<bool, LayoutError> {
|
||||||
if self.mode() == mode {
|
if self.mode() == mode {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -265,6 +286,7 @@ impl WorkspaceLayout {
|
|||||||
Self::Tree(layout) => {
|
Self::Tree(layout) => {
|
||||||
let windows = layout.window_ids_in_reading_order(bounds)?;
|
let windows = layout.window_ids_in_reading_order(bounds)?;
|
||||||
let mut scrolling = ScrollingLayout::with_default_width(default_column_width);
|
let mut scrolling = ScrollingLayout::with_default_width(default_column_width);
|
||||||
|
scrolling.set_focus_strategy(focus_strategy);
|
||||||
let mut previous = None;
|
let mut previous = None;
|
||||||
|
|
||||||
for window in windows {
|
for window in windows {
|
||||||
@@ -339,22 +361,35 @@ pub struct WorkspaceSet {
|
|||||||
placements: HashMap<WindowId, WindowPlacement>,
|
placements: HashMap<WindowId, WindowPlacement>,
|
||||||
default_layout_mode: LayoutMode,
|
default_layout_mode: LayoutMode,
|
||||||
default_column_width: ColumnWidth,
|
default_column_width: ColumnWidth,
|
||||||
|
scrolling_focus_strategy: ViewportFocusStrategy,
|
||||||
next_id: u64,
|
next_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for WorkspaceSet {
|
impl Default for WorkspaceSet {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new(LayoutMode::default(), ColumnWidth::default())
|
Self::new(
|
||||||
|
LayoutMode::default(),
|
||||||
|
ColumnWidth::default(),
|
||||||
|
ViewportFocusStrategy::default(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceSet {
|
impl WorkspaceSet {
|
||||||
pub fn new(default_layout_mode: LayoutMode, default_column_width: ColumnWidth) -> Self {
|
pub fn new(
|
||||||
|
default_layout_mode: LayoutMode,
|
||||||
|
default_column_width: ColumnWidth,
|
||||||
|
scrolling_focus_strategy: ViewportFocusStrategy,
|
||||||
|
) -> Self {
|
||||||
let active = WorkspaceId(1);
|
let active = WorkspaceId(1);
|
||||||
let workspace = Workspace {
|
let workspace = Workspace {
|
||||||
id: active,
|
id: active,
|
||||||
name: "1".to_owned(),
|
name: "1".to_owned(),
|
||||||
layout: WorkspaceLayout::new(default_layout_mode, default_column_width),
|
layout: WorkspaceLayout::new(
|
||||||
|
default_layout_mode,
|
||||||
|
default_column_width,
|
||||||
|
scrolling_focus_strategy,
|
||||||
|
),
|
||||||
floating: Vec::new(),
|
floating: Vec::new(),
|
||||||
last_focused: None,
|
last_focused: None,
|
||||||
fullscreen: None,
|
fullscreen: None,
|
||||||
@@ -370,6 +405,7 @@ impl WorkspaceSet {
|
|||||||
placements: HashMap::new(),
|
placements: HashMap::new(),
|
||||||
default_layout_mode,
|
default_layout_mode,
|
||||||
default_column_width,
|
default_column_width,
|
||||||
|
scrolling_focus_strategy,
|
||||||
next_id: 2,
|
next_id: 2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -423,11 +459,16 @@ impl WorkspaceSet {
|
|||||||
bounds: Rect,
|
bounds: Rect,
|
||||||
) -> Result<bool, WorkspaceError> {
|
) -> Result<bool, WorkspaceError> {
|
||||||
let default_column_width = self.default_column_width;
|
let default_column_width = self.default_column_width;
|
||||||
|
let focus_strategy = self.scrolling_focus_strategy;
|
||||||
let workspace = self.active_mut();
|
let workspace = self.active_mut();
|
||||||
let focused = tiled_focus(workspace);
|
let focused = tiled_focus(workspace);
|
||||||
let changed = workspace
|
let changed = workspace.layout.set_mode(
|
||||||
.layout
|
mode,
|
||||||
.set_mode(mode, bounds, focused, default_column_width)?;
|
bounds,
|
||||||
|
focused,
|
||||||
|
default_column_width,
|
||||||
|
focus_strategy,
|
||||||
|
)?;
|
||||||
|
|
||||||
debug_assert!(self.validate().is_ok());
|
debug_assert!(self.validate().is_ok());
|
||||||
Ok(changed)
|
Ok(changed)
|
||||||
@@ -588,7 +629,11 @@ impl WorkspaceSet {
|
|||||||
Workspace {
|
Workspace {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
layout: WorkspaceLayout::new(self.default_layout_mode, self.default_column_width),
|
layout: WorkspaceLayout::new(
|
||||||
|
self.default_layout_mode,
|
||||||
|
self.default_column_width,
|
||||||
|
self.scrolling_focus_strategy,
|
||||||
|
),
|
||||||
floating: Vec::new(),
|
floating: Vec::new(),
|
||||||
last_focused: None,
|
last_focused: None,
|
||||||
fullscreen: None,
|
fullscreen: None,
|
||||||
@@ -914,11 +959,13 @@ impl WorkspaceSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn first_window(workspace: &Workspace) -> Option<WindowId> {
|
fn first_window(workspace: &Workspace) -> Option<WindowId> {
|
||||||
workspace
|
workspace.layout.preferred_window().or_else(|| {
|
||||||
.layout
|
workspace
|
||||||
.window_ids()
|
.floating
|
||||||
.chain(workspace.floating.iter().copied())
|
.iter()
|
||||||
.min_by_key(|window| window.0)
|
.copied()
|
||||||
|
.min_by_key(|window| window.0)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tiled_focus(workspace: &Workspace) -> Option<WindowId> {
|
fn tiled_focus(workspace: &Workspace) -> Option<WindowId> {
|
||||||
@@ -959,7 +1006,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn configured_layout_defaults_apply_to_new_workspaces() {
|
fn configured_layout_defaults_apply_to_new_workspaces() {
|
||||||
let mut workspaces = WorkspaceSet::new(LayoutMode::Scrolling, ColumnWidth::Full);
|
let mut workspaces = WorkspaceSet::new(
|
||||||
|
LayoutMode::Scrolling,
|
||||||
|
ColumnWidth::Full,
|
||||||
|
ViewportFocusStrategy::Minimal,
|
||||||
|
);
|
||||||
workspaces
|
workspaces
|
||||||
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
|
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1044,6 +1095,21 @@ mod tests {
|
|||||||
assert!(workspaces.validate().is_ok());
|
assert!(workspaces.validate().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closing_a_scrolling_column_focuses_its_adjacent_column() {
|
||||||
|
let mut workspaces = WorkspaceSet::default();
|
||||||
|
for id in 1..=4 {
|
||||||
|
workspaces
|
||||||
|
.insert_window(WindowId(id), Axis::Horizontal, 0.5)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
workspaces.focus_window(WindowId(3)).unwrap();
|
||||||
|
|
||||||
|
workspaces.remove_window(WindowId(3)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(workspaces.active().last_focused, Some(WindowId(4)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn focus_rejects_a_window_on_an_inactive_workspace() {
|
fn focus_rejects_a_window_on_an_inactive_workspace() {
|
||||||
let mut workspaces = WorkspaceSet::default();
|
let mut workspaces = WorkspaceSet::default();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::fmt;
|
|||||||
|
|
||||||
mod scrolling;
|
mod scrolling;
|
||||||
|
|
||||||
pub use scrolling::{Column, ColumnWidth, ScrollingLayout};
|
pub use scrolling::{Column, ColumnWidth, ScrollingLayout, ViewportFocusStrategy};
|
||||||
|
|
||||||
const MIN_SPLIT_RATIO: f64 = 0.05;
|
const MIN_SPLIT_RATIO: f64 = 0.05;
|
||||||
const MAX_SPLIT_RATIO: f64 = 0.95;
|
const MAX_SPLIT_RATIO: f64 = 0.95;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const DEFAULT_WIDTH: f64 = 0.5;
|
|||||||
const MIN_COLUMN_WIDTH: f64 = 0.1;
|
const MIN_COLUMN_WIDTH: f64 = 0.1;
|
||||||
const MAX_COLUMN_WIDTH: f64 = 2.0;
|
const MAX_COLUMN_WIDTH: f64 = 2.0;
|
||||||
const MIN_ROW_HEIGHT: f64 = 0.05;
|
const MIN_ROW_HEIGHT: f64 = 0.05;
|
||||||
|
const COMFORT_START: f64 = 0.2;
|
||||||
|
const COMFORT_END: f64 = 0.8;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub enum ColumnWidth {
|
pub enum ColumnWidth {
|
||||||
@@ -17,6 +19,13 @@ pub enum ColumnWidth {
|
|||||||
Full,
|
Full,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub enum ViewportFocusStrategy {
|
||||||
|
#[default]
|
||||||
|
Minimal,
|
||||||
|
Center,
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for ColumnWidth {
|
impl Default for ColumnWidth {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::Proportion(DEFAULT_WIDTH)
|
Self::Proportion(DEFAULT_WIDTH)
|
||||||
@@ -67,6 +76,8 @@ pub struct ScrollingLayout {
|
|||||||
viewport_x: f64,
|
viewport_x: f64,
|
||||||
neighbor_context: f64,
|
neighbor_context: f64,
|
||||||
default_width: ColumnWidth,
|
default_width: ColumnWidth,
|
||||||
|
focus_strategy: ViewportFocusStrategy,
|
||||||
|
reveal_pending: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ScrollingLayout {
|
impl Default for ScrollingLayout {
|
||||||
@@ -77,6 +88,8 @@ impl Default for ScrollingLayout {
|
|||||||
viewport_x: 0.0,
|
viewport_x: 0.0,
|
||||||
neighbor_context: 48.0,
|
neighbor_context: 48.0,
|
||||||
default_width: ColumnWidth::default(),
|
default_width: ColumnWidth::default(),
|
||||||
|
focus_strategy: ViewportFocusStrategy::Minimal,
|
||||||
|
reveal_pending: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,6 +102,10 @@ impl ScrollingLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_focus_strategy(&mut self, strategy: ViewportFocusStrategy) {
|
||||||
|
self.focus_strategy = strategy;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn default_width(&self) -> ColumnWidth {
|
pub fn default_width(&self) -> ColumnWidth {
|
||||||
self.default_width
|
self.default_width
|
||||||
}
|
}
|
||||||
@@ -101,6 +118,11 @@ impl ScrollingLayout {
|
|||||||
self.active_column
|
self.active_column
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn active_window(&self) -> Option<WindowId> {
|
||||||
|
let column = self.columns.get(self.active_column?)?;
|
||||||
|
column.windows.get(column.active).copied()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn viewport_x(&self) -> f64 {
|
pub fn viewport_x(&self) -> f64 {
|
||||||
self.viewport_x
|
self.viewport_x
|
||||||
}
|
}
|
||||||
@@ -131,6 +153,7 @@ impl ScrollingLayout {
|
|||||||
self.columns
|
self.columns
|
||||||
.insert(index, Column::new(window, self.default_width));
|
.insert(index, Column::new(window, self.default_width));
|
||||||
self.active_column = Some(index);
|
self.active_column = Some(index);
|
||||||
|
self.reveal_pending = self.columns.len() > 1;
|
||||||
|
|
||||||
debug_assert!(self.validate().is_ok());
|
debug_assert!(self.validate().is_ok());
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -141,6 +164,8 @@ impl ScrollingLayout {
|
|||||||
.window_location(window)
|
.window_location(window)
|
||||||
.ok_or(LayoutError::UnknownWindow(window))?;
|
.ok_or(LayoutError::UnknownWindow(window))?;
|
||||||
let column = &mut self.columns[column_index];
|
let column = &mut self.columns[column_index];
|
||||||
|
let removed_active_column =
|
||||||
|
self.active_column == Some(column_index) && column.windows.len() == 1;
|
||||||
column.windows.remove(window_index);
|
column.windows.remove(window_index);
|
||||||
column.heights.remove(window_index);
|
column.heights.remove(window_index);
|
||||||
|
|
||||||
@@ -156,6 +181,7 @@ impl ScrollingLayout {
|
|||||||
column.active = column.active.min(column.windows.len() - 1);
|
column.active = column.active.min(column.windows.len() - 1);
|
||||||
column.normalize_heights();
|
column.normalize_heights();
|
||||||
}
|
}
|
||||||
|
self.reveal_pending |= removed_active_column && !self.columns.is_empty();
|
||||||
|
|
||||||
debug_assert!(self.validate().is_ok());
|
debug_assert!(self.validate().is_ok());
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -165,8 +191,10 @@ impl ScrollingLayout {
|
|||||||
let (column, index) = self
|
let (column, index) = self
|
||||||
.window_location(window)
|
.window_location(window)
|
||||||
.ok_or(LayoutError::UnknownWindow(window))?;
|
.ok_or(LayoutError::UnknownWindow(window))?;
|
||||||
|
let changed_column = self.active_column != Some(column);
|
||||||
self.active_column = Some(column);
|
self.active_column = Some(column);
|
||||||
self.columns[column].active = index;
|
self.columns[column].active = index;
|
||||||
|
self.reveal_pending |= changed_column;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +219,7 @@ impl ScrollingLayout {
|
|||||||
column.active = insertion;
|
column.active = insertion;
|
||||||
column.normalize_heights();
|
column.normalize_heights();
|
||||||
self.active_column = Some(target_column);
|
self.active_column = Some(target_column);
|
||||||
|
self.reveal_pending = true;
|
||||||
|
|
||||||
debug_assert!(self.validate().is_ok());
|
debug_assert!(self.validate().is_ok());
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -210,6 +239,7 @@ impl ScrollingLayout {
|
|||||||
self.columns
|
self.columns
|
||||||
.insert(insertion, Column::new(window, self.default_width));
|
.insert(insertion, Column::new(window, self.default_width));
|
||||||
self.active_column = Some(insertion);
|
self.active_column = Some(insertion);
|
||||||
|
self.reveal_pending = true;
|
||||||
|
|
||||||
debug_assert!(self.validate().is_ok());
|
debug_assert!(self.validate().is_ok());
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -405,7 +435,7 @@ impl ScrollingLayout {
|
|||||||
let viewport_width = (bounds.width - outer * 2.0).max(1.0);
|
let viewport_width = (bounds.width - outer * 2.0).max(1.0);
|
||||||
let viewport_height = (bounds.height - outer * 2.0).max(1.0);
|
let viewport_height = (bounds.height - outer * 2.0).max(1.0);
|
||||||
let mut column_positions = Vec::with_capacity(self.columns.len());
|
let mut column_positions = Vec::with_capacity(self.columns.len());
|
||||||
let mut strip_width = 0.0;
|
let mut next_column_x = 0.0;
|
||||||
|
|
||||||
for column in &self.columns {
|
for column in &self.columns {
|
||||||
let requested = match normalized_width(column.width) {
|
let requested = match normalized_width(column.width) {
|
||||||
@@ -428,11 +458,13 @@ impl ScrollingLayout {
|
|||||||
})
|
})
|
||||||
.fold(1.0_f64, f64::max);
|
.fold(1.0_f64, f64::max);
|
||||||
let width = requested.max(minimum).max(1.0);
|
let width = requested.max(minimum).max(1.0);
|
||||||
column_positions.push((strip_width, width));
|
column_positions.push((next_column_x, width));
|
||||||
strip_width += width + inner;
|
next_column_x += width + inner;
|
||||||
|
}
|
||||||
|
if self.reveal_pending {
|
||||||
|
self.reveal_active_column(&column_positions, viewport_width);
|
||||||
|
self.reveal_pending = false;
|
||||||
}
|
}
|
||||||
strip_width = (strip_width - inner).max(0.0);
|
|
||||||
self.reveal_active_column(&column_positions, strip_width, viewport_width);
|
|
||||||
|
|
||||||
let mut result = LayoutResult::default();
|
let mut result = LayoutResult::default();
|
||||||
for (column_index, column) in self.columns.iter().enumerate() {
|
for (column_index, column) in self.columns.iter().enumerate() {
|
||||||
@@ -464,25 +496,36 @@ impl ScrollingLayout {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reveal_active_column(
|
fn reveal_active_column(&mut self, positions: &[(f64, f64)], viewport_width: f64) {
|
||||||
&mut self,
|
|
||||||
positions: &[(f64, f64)],
|
|
||||||
strip_width: f64,
|
|
||||||
viewport_width: f64,
|
|
||||||
) {
|
|
||||||
let max_scroll = (strip_width - viewport_width).max(0.0);
|
|
||||||
self.viewport_x = finite_nonnegative(self.viewport_x).min(max_scroll);
|
|
||||||
let Some(active) = self.active_column else {
|
let Some(active) = self.active_column else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let (start, width) = positions[active];
|
let (start, width) = positions[active];
|
||||||
let end = start + width;
|
let end = start + width;
|
||||||
let context = finite_nonnegative(self.neighbor_context).min(viewport_width / 3.0);
|
if self.focus_strategy == ViewportFocusStrategy::Center {
|
||||||
|
self.viewport_x = start + width / 2.0 - viewport_width / 2.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if start < self.viewport_x + context {
|
if start >= self.viewport_x && end <= self.viewport_x + viewport_width {
|
||||||
self.viewport_x = (start - context).max(0.0);
|
return;
|
||||||
} else if end > self.viewport_x + viewport_width - context {
|
}
|
||||||
self.viewport_x = (end - viewport_width + context).min(max_scroll);
|
|
||||||
|
let comfort_start = viewport_width * COMFORT_START;
|
||||||
|
let comfort_end = viewport_width * COMFORT_END;
|
||||||
|
let comfort_width = comfort_end - comfort_start;
|
||||||
|
|
||||||
|
if width > comfort_width {
|
||||||
|
let context = finite_nonnegative(self.neighbor_context).min(viewport_width / 3.0);
|
||||||
|
if start > self.viewport_x + comfort_start {
|
||||||
|
self.viewport_x = start - context;
|
||||||
|
} else if end < self.viewport_x + comfort_end {
|
||||||
|
self.viewport_x = end - viewport_width + context;
|
||||||
|
}
|
||||||
|
} else if start < self.viewport_x + comfort_start {
|
||||||
|
self.viewport_x = start - comfort_start;
|
||||||
|
} else if end > self.viewport_x + comfort_end {
|
||||||
|
self.viewport_x = end - comfort_end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +540,7 @@ impl ScrollingLayout {
|
|||||||
if self.columns[source].windows.len() == 1 {
|
if self.columns[source].windows.len() == 1 {
|
||||||
self.columns.swap(source, destination);
|
self.columns.swap(source, destination);
|
||||||
self.active_column = Some(destination);
|
self.active_column = Some(destination);
|
||||||
|
self.reveal_pending = true;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,6 +554,7 @@ impl ScrollingLayout {
|
|||||||
self.columns
|
self.columns
|
||||||
.insert(insertion, Column::new(window, self.default_width));
|
.insert(insertion, Column::new(window, self.default_width));
|
||||||
self.active_column = Some(insertion);
|
self.active_column = Some(insertion);
|
||||||
|
self.reveal_pending = true;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,7 +568,7 @@ impl ScrollingLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate(&self) -> Result<(), LayoutError> {
|
pub fn validate(&self) -> Result<(), LayoutError> {
|
||||||
if !self.viewport_x.is_finite() || self.viewport_x < 0.0 {
|
if !self.viewport_x.is_finite() {
|
||||||
return Err(LayoutError::InvalidTree(
|
return Err(LayoutError::InvalidTree(
|
||||||
"scrolling viewport offset is invalid",
|
"scrolling viewport offset is invalid",
|
||||||
));
|
));
|
||||||
@@ -701,8 +746,8 @@ mod tests {
|
|||||||
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(4)))
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(4)))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(layout.viewport_x(), 1_000.0);
|
assert_eq!(layout.viewport_x(), 1_200.0);
|
||||||
assert_eq!(result.geometry[&window(4)].x, 500.0);
|
assert_eq!(result.geometry[&window(4)].x, 300.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -737,6 +782,85 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_resize_does_not_retarget_the_viewport() {
|
||||||
|
let mut layout = ScrollingLayout::default();
|
||||||
|
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
|
||||||
|
let gaps = GapConfig {
|
||||||
|
inner: 0.0,
|
||||||
|
outer: 0.0,
|
||||||
|
smart: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for id in 1..=3 {
|
||||||
|
layout
|
||||||
|
.insert(window(id), (id > 1).then(|| window(id - 1)))
|
||||||
|
.unwrap();
|
||||||
|
layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(id)))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let before = layout.viewport_x();
|
||||||
|
|
||||||
|
layout
|
||||||
|
.resize_window(window(3), Direction::Right, 0.5)
|
||||||
|
.unwrap();
|
||||||
|
layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(3)))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(layout.viewport_x(), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn viewport_is_not_clamped_to_the_column_strip() {
|
||||||
|
let mut layout = ScrollingLayout::default();
|
||||||
|
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
|
||||||
|
let gaps = GapConfig {
|
||||||
|
inner: 0.0,
|
||||||
|
outer: 0.0,
|
||||||
|
smart: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for id in 1..=3 {
|
||||||
|
layout.insert(window(id), None).unwrap();
|
||||||
|
}
|
||||||
|
layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(3)))
|
||||||
|
.unwrap();
|
||||||
|
layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(1)))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(layout.viewport_x(), -200.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closing_non_focused_column_does_not_move_viewport() {
|
||||||
|
let mut layout = ScrollingLayout::default();
|
||||||
|
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
|
||||||
|
let gaps = GapConfig {
|
||||||
|
inner: 0.0,
|
||||||
|
outer: 0.0,
|
||||||
|
smart: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for id in 1..=4 {
|
||||||
|
layout.insert(window(id), None).unwrap();
|
||||||
|
}
|
||||||
|
layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(4)))
|
||||||
|
.unwrap();
|
||||||
|
let before = layout.viewport_x();
|
||||||
|
|
||||||
|
layout.remove(window(1)).unwrap();
|
||||||
|
layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(4)))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(layout.viewport_x(), before);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn grouping_and_extracting_preserve_membership() {
|
fn grouping_and_extracting_preserve_membership() {
|
||||||
let mut layout = ScrollingLayout::default();
|
let mut layout = ScrollingLayout::default();
|
||||||
|
|||||||
@@ -465,19 +465,77 @@ Scrolling layouts maintain these invariants after every transaction:
|
|||||||
3. Proportional and fixed widths are positive and finite. Height weights are
|
3. Proportional and fixed widths are positive and finite. Height weights are
|
||||||
positive, finite, match the column's window count, and normalize
|
positive, finite, match the column's window count, and normalize
|
||||||
deterministically.
|
deterministically.
|
||||||
4. `viewport_x` is finite and clamped to the strip, while permitting the final
|
4. `viewport_x` is finite. It is an unbounded world-space offset and MUST NOT be
|
||||||
column to align with either viewport edge.
|
clamped to the populated column strip.
|
||||||
|
|
||||||
Scrolling columns form a horizontal strip that MAY be wider than the usable
|
Scrolling columns form a horizontal strip that MAY be wider than the usable
|
||||||
output. Windows within a column divide its height. The viewport follows focus
|
output. Windows within a column divide its height; there is no vertical
|
||||||
only as far as required to reveal the focused window and SHOULD retain part of
|
scrolling. Window geometry uses stable workspace coordinates and presentation
|
||||||
an adjacent column when space permits. It MUST NOT recenter on every focus
|
subtracts the workspace viewport:
|
||||||
change. An explicit center command MAY center the focused column.
|
|
||||||
|
```text
|
||||||
|
screen_x = window_world_x - viewport_x
|
||||||
|
```
|
||||||
|
|
||||||
|
Navigation animates one viewport spring per visible scrolling workspace.
|
||||||
|
Existing windows MUST NOT receive independent horizontal motion for the same
|
||||||
|
navigation. Retargeting while the viewport is moving preserves its current
|
||||||
|
interpolated position and velocity; viewport moves are not queued.
|
||||||
|
|
||||||
|
Viewport translation uses a dedicated critically damped spring, separate from
|
||||||
|
window geometry:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mass = 1.0
|
||||||
|
stiffness = 320.0
|
||||||
|
damping_ratio = 1.0
|
||||||
|
damping = 2 * damping_ratio * sqrt(stiffness * mass) # approximately 35.8
|
||||||
|
```
|
||||||
|
|
||||||
|
It MUST NOT visibly overshoot. Rapid navigation retargets this same spring
|
||||||
|
immediately and may coalesce commands received before the next presented frame.
|
||||||
|
Same-direction velocity is preserved. Direction reversal preserves the current
|
||||||
|
position, reduces opposing velocity when necessary, and reverses without a
|
||||||
|
snap.
|
||||||
|
|
||||||
|
New normal windows insert immediately after the focused column at their final
|
||||||
|
world position and become focused. The compositor then computes the minimum
|
||||||
|
viewport movement needed to expose the new column. A small local reveal using
|
||||||
|
opacity, scale, or an x offset MAY supplement this motion, but the viewport is
|
||||||
|
the primary animation.
|
||||||
|
|
||||||
|
When the focused column closes, focus moves to an adjacent column and the
|
||||||
|
viewport retargets to reveal it. Closing a non-focused column MUST NOT move the
|
||||||
|
viewport. Manual column resize also MUST NOT retarget it; a subsequent
|
||||||
|
navigation, open, or focused-close operation may do so.
|
||||||
|
|
||||||
|
During an in-flight viewport transition, resize leaves the viewport target and
|
||||||
|
motion intact while affected world geometry uses the normal window spring. The
|
||||||
|
composed transform is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
screen_x = animated_world_x - animated_viewport_x
|
||||||
|
```
|
||||||
|
|
||||||
|
Resize MUST NOT restart or cancel the viewport spring. If reflow would make the
|
||||||
|
focused window completely unreachable, Ferese MAY apply only the minimum
|
||||||
|
visibility correction, not normal comfort-region recentering.
|
||||||
|
|
||||||
|
The default reveal strategy avoids scrolling when the focused column is already
|
||||||
|
fully visible. Otherwise it moves only enough to place the column in a comfort
|
||||||
|
region approximately 20% through 80% of the viewport. It MUST NOT center every
|
||||||
|
focus change. `focus_strategy = "center_on_focus"` selects that alternative,
|
||||||
|
and the explicit center command always centers the focused column. When a large
|
||||||
|
column cannot fit the comfort region, Ferese SHOULD retain 40–80 logical pixels
|
||||||
|
of the previous column where possible.
|
||||||
|
|
||||||
|
Each scrolling workspace owns its viewport. Outputs animate their hosted
|
||||||
|
workspaces independently; neither columns nor a viewport span outputs.
|
||||||
|
|
||||||
Opening, closing, moving, or resizing a window MUST preserve a stable visual
|
Opening, closing, moving, or resizing a window MUST preserve a stable visual
|
||||||
anchor: columns before the focused column do not jump merely because a later
|
anchor: columns before the focused column do not jump merely because a later
|
||||||
column changed. Each workspace stores and restores its own viewport. Output
|
column changed. Each workspace stores and restores its own viewport. Output
|
||||||
resize or migration clamps that viewport without discarding it. Constraint
|
resize or migration preserves that viewport without imposing strip bounds. Constraint
|
||||||
resolution MUST prefer the focused column, preserve user-selected widths where
|
resolution MUST prefer the focused column, preserve user-selected widths where
|
||||||
possible, and report conflicts rather than silently compressing every column to
|
possible, and report conflicts rather than silently compressing every column to
|
||||||
fit. These stable anchors, deterministic constraints, and retained neighboring
|
fit. These stable anchors, deterministic constraints, and retained neighboring
|
||||||
@@ -1016,6 +1074,11 @@ stiffness = 700.0
|
|||||||
damping = 53.0
|
damping = 53.0
|
||||||
mass = 1.0
|
mass = 1.0
|
||||||
|
|
||||||
|
[animations.viewport_spring]
|
||||||
|
stiffness = 320.0
|
||||||
|
damping_ratio = 1.0
|
||||||
|
mass = 1.0
|
||||||
|
|
||||||
[layout]
|
[layout]
|
||||||
mode = "scrolling"
|
mode = "scrolling"
|
||||||
inner_gap = 10.0
|
inner_gap = 10.0
|
||||||
@@ -1026,6 +1089,7 @@ smart_gaps = true
|
|||||||
default_column_width = 0.5
|
default_column_width = 0.5
|
||||||
# Use 1.0 or "full" to open every new column at the viewport width.
|
# Use 1.0 or "full" to open every new column at the viewport width.
|
||||||
width_presets = [0.333333, 0.5, 0.666667, 1.0]
|
width_presets = [0.333333, 0.5, 0.666667, 1.0]
|
||||||
|
focus_strategy = "minimal"
|
||||||
neighbor_context = 48.0
|
neighbor_context = 48.0
|
||||||
new_window = "column_after_focused"
|
new_window = "column_after_focused"
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user