From cf61c080b6d177ff034c00736f4b1acfdb3f1530 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 17:19:46 +1000 Subject: [PATCH] Add custom animation curve config --- docs/window-animations-plan.md | 9 +- jay-config/src/_private/client.rs | 4 + jay-config/src/_private/ipc.rs | 6 + jay-config/src/lib.rs | 8 + src/animation.rs | 170 +++++++++++++++---- src/config/handler.rs | 9 + src/state.rs | 8 + toml-config/src/config.rs | 21 ++- toml-config/src/config/parsers/animations.rs | 57 ++++++- toml-config/src/lib.rs | 46 +++-- 10 files changed, 281 insertions(+), 57 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index 74f6564c..6b2261ff 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -221,10 +221,15 @@ Initial TOML shape: enabled = false duration-ms = 160 curve = "ease-out" +# or: +curve = [0.25, 0.1, 0.25, 1.0] ``` -Bezier curves should be analyzed at configuration time and stored in a form that -is cheap to evaluate during rendering. +Bezier curves are analyzed when configuration is applied and stored as a +piecewise curve that is cheap to evaluate during rendering. Custom curves use +CSS cubic-bezier semantics: `(0, 0)` and `(1, 1)` are implicit, while the four +configured numbers are `x1`, `y1`, `x2`, and `y2`. The x control points must be +between `0` and `1`. ## Existing Note diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 721b5097..09a96527 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -1035,6 +1035,10 @@ impl ConfigClient { self.send(&ClientMessage::SetAnimationCurve { curve }); } + pub fn set_animation_cubic_bezier(&self, x1: f32, y1: f32, x2: f32, y2: f32) { + self.send(&ClientMessage::SetAnimationCubicBezier { x1, y1, x2, y2 }); + } + pub fn set_color_management_enabled(&self, enabled: bool) { self.send(&ClientMessage::SetColorManagementEnabled { enabled }); } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index d090ba0c..f0c8aa67 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -554,6 +554,12 @@ pub enum ClientMessage<'a> { SetAnimationCurve { curve: u32, }, + SetAnimationCubicBezier { + x1: f32, + y1: f32, + x2: f32, + y2: f32, + }, SetXScalingMode { mode: XScalingMode, }, diff --git a/jay-config/src/lib.rs b/jay-config/src/lib.rs index 44546ce0..fc8915ee 100644 --- a/jay-config/src/lib.rs +++ b/jay-config/src/lib.rs @@ -320,6 +320,14 @@ pub fn set_animation_curve(curve: AnimationCurve) { get!().set_animation_curve(curve.0); } +/// Sets a custom cubic-bezier curve used by tiled window animations. +/// +/// `x1` and `x2` must be between `0.0` and `1.0`. The curve starts at `(0, 0)` +/// and ends at `(1, 1)`. +pub fn set_animation_cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) { + get!().set_animation_cubic_bezier(x1, y1, x2, y2); +} + /// Enables or disables the color-management protocol. /// /// The default is `false`. diff --git a/src/animation.rs b/src/animation.rs index 199e142b..92cbfa74 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -17,41 +17,113 @@ use { }; const DEFAULT_DURATION_MS: u32 = 160; +const CURVE_MAX_POINTS: usize = 33; +const CURVE_FLATNESS_EPSILON: f32 = 0.001; +const CURVE_MAX_DEPTH: u8 = 8; const SPAWN_IN_INITIAL_SCALE_NUMERATOR: i32 = 4; const SPAWN_IN_INITIAL_SCALE_DENOMINATOR: i32 = 5; -#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[derive(Copy, Clone, Debug, PartialEq)] pub enum AnimationCurve { Linear, - Ease, - EaseIn, - EaseOut, - EaseInOut, + Piecewise(PiecewiseCurve), } impl AnimationCurve { pub fn from_config(value: u32) -> Self { match value { 0 => Self::Linear, - 1 => Self::Ease, - 2 => Self::EaseIn, - 4 => Self::EaseInOut, - _ => Self::EaseOut, + 1 => Self::from_cubic_bezier(0.25, 0.1, 0.25, 1.0).unwrap(), + 2 => Self::from_cubic_bezier(0.42, 0.0, 1.0, 1.0).unwrap(), + 4 => Self::from_cubic_bezier(0.42, 0.0, 0.58, 1.0).unwrap(), + _ => Self::from_cubic_bezier(0.0, 0.0, 0.58, 1.0).unwrap(), } } + pub fn from_cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> Option { + if !x1.is_finite() + || !y1.is_finite() + || !x2.is_finite() + || !y2.is_finite() + || !(0.0..=1.0).contains(&x1) + || !(0.0..=1.0).contains(&x2) + { + return None; + } + Some(Self::Piecewise(PiecewiseCurve::from_cubic_bezier( + x1, y1, x2, y2, + ))) + } + fn sample(self, t: f64) -> f64 { let t = t.clamp(0.0, 1.0); match self { Self::Linear => t, - Self::Ease => cubic_bezier(0.25, 0.1, 0.25, 1.0, t), - Self::EaseIn => cubic_bezier(0.42, 0.0, 1.0, 1.0, t), - Self::EaseOut => cubic_bezier(0.0, 0.0, 0.58, 1.0, t), - Self::EaseInOut => cubic_bezier(0.42, 0.0, 0.58, 1.0, t), + Self::Piecewise(curve) => curve.sample(t as f32) as f64, } } } +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct PiecewiseCurve { + len: u8, + points: [CurvePoint; CURVE_MAX_POINTS], +} + +impl PiecewiseCurve { + fn from_cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> Self { + let mut points = Vec::with_capacity(CURVE_MAX_POINTS); + let p0 = cubic_bezier_point(x1, y1, x2, y2, 0.0); + let p1 = cubic_bezier_point(x1, y1, x2, y2, 1.0); + points.push(p0); + flatten_cubic_bezier(&mut points, (x1, y1, x2, y2), 0.0, p0, 1.0, p1, 0); + let mut array = [CurvePoint::default(); CURVE_MAX_POINTS]; + let len = points.len().min(CURVE_MAX_POINTS); + array[..len].copy_from_slice(&points[..len]); + Self { + len: len as u8, + points: array, + } + } + + fn sample(self, x: f32) -> f32 { + let len = self.len as usize; + if len <= 1 { + return x; + } + let points = &self.points[..len]; + if x <= points[0].x { + return points[0].y; + } + if x >= points[len - 1].x { + return points[len - 1].y; + } + let mut lo = 0; + let mut hi = len - 1; + while lo + 1 < hi { + let mid = (lo + hi) / 2; + if points[mid].x <= x { + lo = mid; + } else { + hi = mid; + } + } + let from = points[lo]; + let to = points[hi]; + if to.x <= from.x { + return to.y; + } + let t = (x - from.x) / (to.x - from.x); + from.y + (to.y - from.y) * t + } +} + +#[derive(Copy, Clone, Debug, Default, PartialEq)] +struct CurvePoint { + x: f32, + y: f32, +} + pub struct AnimationState { pub enabled: Cell, pub duration_ms: Cell, @@ -187,7 +259,7 @@ impl Default for AnimationState { Self { enabled: Cell::new(false), duration_ms: Cell::new(DEFAULT_DURATION_MS), - curve: Cell::new(AnimationCurve::EaseOut), + curve: Cell::new(AnimationCurve::from_config(3)), windows: Default::default(), exits: Default::default(), tick: Default::default(), @@ -527,27 +599,43 @@ pub(crate) fn expand_damage_rect(rect: Rect, width: i32) -> Rect { ) } -fn cubic_bezier(x1: f64, y1: f64, x2: f64, y2: f64, x: f64) -> f64 { - fn bezier(a: f64, b: f64, t: f64) -> f64 { +fn flatten_cubic_bezier( + points: &mut Vec, + controls: (f32, f32, f32, f32), + t0: f32, + p0: CurvePoint, + t1: f32, + p1: CurvePoint, + depth: u8, +) { + let tm = (t0 + t1) * 0.5; + let pm = cubic_bezier_point(controls.0, controls.1, controls.2, controls.3, tm); + let projected_y = if p1.x <= p0.x { + (p0.y + p1.y) * 0.5 + } else { + let tx = (pm.x - p0.x) / (p1.x - p0.x); + p0.y + (p1.y - p0.y) * tx + }; + if (pm.y - projected_y).abs() > CURVE_FLATNESS_EPSILON + && depth < CURVE_MAX_DEPTH + && points.len() + 2 < CURVE_MAX_POINTS + { + flatten_cubic_bezier(points, controls, t0, p0, tm, pm, depth + 1); + flatten_cubic_bezier(points, controls, tm, pm, t1, p1, depth + 1); + } else { + points.push(p1); + } +} + +fn cubic_bezier_point(x1: f32, y1: f32, x2: f32, y2: f32, t: f32) -> CurvePoint { + fn bezier(a: f32, b: f32, t: f32) -> f32 { let inv = 1.0 - t; 3.0 * inv * inv * t * a + 3.0 * inv * t * t * b + t * t * t } - let mut lo = 0.0; - let mut hi = 1.0; - let mut t = x; - for _ in 0..12 { - let bx = bezier(x1, x2, t); - if (bx - x).abs() < 0.000_001 { - break; - } - if bx < x { - lo = t; - } else { - hi = t; - } - t = (lo + hi) * 0.5; + CurvePoint { + x: bezier(x1, x2, t), + y: bezier(y1, y2, t), } - bezier(y1, y2, t) } #[cfg(test)] @@ -561,6 +649,26 @@ mod tests { assert_eq!(lerp_rect(a, b, 0.25), lerp_rect(b, a, 0.75)); } + #[test] + fn custom_cubic_bezier_curve_is_prepared() { + let curve = AnimationCurve::from_cubic_bezier(0.0, 0.0, 1.0, 1.0).unwrap(); + assert_eq!(curve.sample(0.0), 0.0); + assert_eq!(curve.sample(1.0), 1.0); + assert!((curve.sample(0.5) - 0.5).abs() < 0.001); + + let ease_out = AnimationCurve::from_cubic_bezier(0.0, 0.0, 0.58, 1.0).unwrap(); + let mid = ease_out.sample(0.5); + assert!(mid > 0.5); + assert!(mid < 1.0); + } + + #[test] + fn invalid_custom_cubic_bezier_curve_is_rejected() { + assert!(AnimationCurve::from_cubic_bezier(-0.1, 0.0, 0.58, 1.0).is_none()); + assert!(AnimationCurve::from_cubic_bezier(0.0, 0.0, 1.1, 1.0).is_none()); + assert!(AnimationCurve::from_cubic_bezier(0.0, f32::NAN, 0.58, 1.0).is_none()); + } + #[test] fn unchanged_target_does_not_restart() { let state = AnimationState::default(); diff --git a/src/config/handler.rs b/src/config/handler.rs index 138b4416..88a64d1d 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -1005,6 +1005,12 @@ impl ConfigProxyHandler { self.state.set_animation_curve(curve); } + fn handle_set_animation_cubic_bezier(&self, x1: f32, y1: f32, x2: f32, y2: f32) { + if !self.state.set_animation_cubic_bezier(x1, y1, x2, y2) { + log::warn!("Ignoring invalid animation cubic-bezier curve"); + } + } + fn handle_set_direct_scanout_enabled( &self, device: Option, @@ -3243,6 +3249,9 @@ impl ConfigProxyHandler { self.handle_set_animation_duration_ms(duration_ms) } ClientMessage::SetAnimationCurve { curve } => self.handle_set_animation_curve(curve), + ClientMessage::SetAnimationCubicBezier { x1, y1, x2, y2 } => { + self.handle_set_animation_cubic_bezier(x1, y1, x2, y2) + } ClientMessage::SetXScalingMode { mode } => self .handle_set_x_scaling_mode(mode) .wrn("set_x_scaling_mode")?, diff --git a/src/state.rs b/src/state.rs index 5ccb0883..adf7b2ca 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1619,6 +1619,14 @@ impl State { .set(AnimationCurve::from_config(curve)); } + pub fn set_animation_cubic_bezier(&self, x1: f32, y1: f32, x2: f32, y2: f32) -> bool { + let Some(curve) = AnimationCurve::from_cubic_bezier(x1, y1, x2, y2) else { + return false; + }; + self.animations.curve.set(curve); + true + } + pub fn with_layout_animations(&self, f: impl FnOnce() -> T) -> T { let prev_requested = self.layout_animations_requested.replace(true); let prev_active = self.layout_animations_active.replace(true); diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index d860d656..8b01c1f4 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -270,7 +270,13 @@ pub struct UiDrag { pub struct Animations { pub enabled: Option, pub duration_ms: Option, - pub curve: Option, + pub curve: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum AnimationCurveConfig { + Preset(String), + CubicBezier([f32; 4]), } #[derive(Debug, Clone)] @@ -659,3 +665,16 @@ fn default_config_parses() { let input = include_bytes!("default-config.toml"); parse_config(input, &Default::default(), |_| ()).unwrap(); } + +#[test] +fn custom_animation_curve_parses() { + let input = b" + [animations] + curve = [0.25, 0.1, 0.25, 1.0] + "; + let config = parse_config(input, &Default::default(), |_| ()).unwrap(); + assert_eq!( + config.animations.curve, + Some(AnimationCurveConfig::CubicBezier([0.25, 0.1, 0.25, 1.0])) + ); +} diff --git a/toml-config/src/config/parsers/animations.rs b/toml-config/src/config/parsers/animations.rs index a8abdf89..938ba7b9 100644 --- a/toml-config/src/config/parsers/animations.rs +++ b/toml-config/src/config/parsers/animations.rs @@ -1,13 +1,13 @@ use { crate::{ config::{ - Animations, + AnimationCurveConfig, Animations, context::Context, - extractor::{Extractor, ExtractorError, bol, n32, opt, recover, str}, + extractor::{Extractor, ExtractorError, bol, n32, opt, recover, val}, parser::{DataType, ParseResult, Parser, UnexpectedDataType}, }, toml::{ - toml_span::{DespanExt, Span, Spanned}, + toml_span::{DespanExt, Span, Spanned, SpannedExt}, toml_value::Value, }, }, @@ -21,6 +21,14 @@ pub enum AnimationsParserError { Expected(#[from] UnexpectedDataType), #[error(transparent)] Extract(#[from] ExtractorError), + #[error("Expected animation curve to be a string or an array")] + CurveType, + #[error("Cubic-bezier animation curves must contain exactly four values")] + CubicBezierLen, + #[error("Cubic-bezier animation curve entries must be finite floats or integers")] + CubicBezierValue, + #[error("Cubic-bezier x control points must be between 0 and 1")] + CubicBezierXRange, } pub struct AnimationsParser<'a>(pub &'a Context<'a>); @@ -39,12 +47,51 @@ impl Parser for AnimationsParser<'_> { let (enabled, duration_ms, curve) = ext.extract(( recover(opt(bol("enabled"))), recover(opt(n32("duration-ms"))), - recover(opt(str("curve"))), + opt(val("curve")), ))?; + let curve = match curve { + Some(curve) => Some(parse_curve(curve)?), + None => None, + }; Ok(Animations { enabled: enabled.despan(), duration_ms: duration_ms.despan(), - curve: curve.despan().map(|s| s.to_string()), + curve, }) } } + +fn parse_curve( + curve: Spanned<&Value>, +) -> Result> { + match curve.value { + Value::String(s) => Ok(AnimationCurveConfig::Preset(s.clone())), + Value::Array(values) => parse_cubic_bezier(curve.span, values), + _ => Err(AnimationsParserError::CurveType.spanned(curve.span)), + } +} + +fn parse_cubic_bezier( + span: Span, + values: &[Spanned], +) -> Result> { + if values.len() != 4 { + return Err(AnimationsParserError::CubicBezierLen.spanned(span)); + } + let mut points = [0.0; 4]; + for (idx, value) in values.iter().enumerate() { + let f = match value.value { + Value::Float(f) => f, + Value::Integer(i) => i as f64, + _ => return Err(AnimationsParserError::CubicBezierValue.spanned(value.span)), + }; + if !f.is_finite() { + return Err(AnimationsParserError::CubicBezierValue.spanned(value.span)); + } + points[idx] = f as f32; + } + if !(0.0..=1.0).contains(&points[0]) || !(0.0..=1.0).contains(&points[2]) { + return Err(AnimationsParserError::CubicBezierXRange.spanned(span)); + } + Ok(AnimationCurveConfig::CubicBezier(points)) +} diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index 1b057985..605e1fd1 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -13,9 +13,9 @@ mod toml; use { crate::{ config::{ - Action, ClientRule, Config, ConfigConnector, ConfigDrmDevice, ConfigKeymap, - ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output, OutputMatch, - SimpleCommand, Status, Theme, WindowRule, parse_config, + Action, AnimationCurveConfig, ClientRule, Config, ConfigConnector, ConfigDrmDevice, + ConfigKeymap, ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output, + OutputMatch, SimpleCommand, Status, Theme, WindowRule, parse_config, }, rules::{MatcherTemp, RuleMapper}, shortcuts::ModeState, @@ -37,8 +37,8 @@ use { is_reload, keyboard::Keymap, logging::{clean_logs_older_than, set_log_level}, - on_devices_enumerated, on_idle, on_unload, quit, reload, set_animation_curve, - set_animation_duration_ms, set_animations_enabled, set_autotile, + on_devices_enumerated, on_idle, on_unload, quit, reload, set_animation_cubic_bezier, + set_animation_curve, set_animation_duration_ms, set_animations_enabled, set_autotile, set_color_management_enabled, set_corner_radius, set_default_workspace_capture, set_explicit_sync_enabled, set_float_above_fullscreen, set_floating_titles, set_idle, set_idle_grace_period, set_middle_click_paste_enabled, set_show_bar, @@ -1652,20 +1652,30 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc Some(AnimationCurve::LINEAR), - "ease" => Some(AnimationCurve::EASE), - "ease-in" => Some(AnimationCurve::EASE_IN), - "ease-out" => Some(AnimationCurve::EASE_OUT), - "ease-in-out" => Some(AnimationCurve::EASE_IN_OUT), - _ => { - log::warn!("Unknown animation curve: {curve_name}"); - None + match config + .animations + .curve + .unwrap_or_else(|| AnimationCurveConfig::Preset("ease-out".to_string())) + { + AnimationCurveConfig::Preset(curve_name) => { + let curve = match curve_name.as_str() { + "linear" => Some(AnimationCurve::LINEAR), + "ease" => Some(AnimationCurve::EASE), + "ease-in" => Some(AnimationCurve::EASE_IN), + "ease-out" => Some(AnimationCurve::EASE_OUT), + "ease-in-out" => Some(AnimationCurve::EASE_IN_OUT), + _ => { + log::warn!("Unknown animation curve: {curve_name}"); + None + } + }; + if let Some(curve) = curve { + set_animation_curve(curve); + } + } + AnimationCurveConfig::CubicBezier([x1, y1, x2, y2]) => { + set_animation_cubic_bezier(x1, y1, x2, y2); } - }; - if let Some(curve) = curve { - set_animation_curve(curve); } if let Some(xwayland) = config.xwayland { if let Some(enabled) = xwayland.enabled {