Skip to main content

open_gpui_motion/
policy.rs

1//! Renderer-neutral motion policy validation.
2
3use crate::spring::{MotionModel, MotionSpringPhysics};
4use std::time::Duration;
5
6/// Maximum routine UI motion duration accepted without a continuity reason.
7pub const MOTION_POLICY_MAX_UI_DURATION: Duration = Duration::from_millis(300);
8
9/// Product context for a motion policy validation.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum MotionPolicyContext {
12    /// Pointer-coupled drag or resize input.
13    PointerDrag,
14    /// Keyboard focus, command focus, or high-frequency focus movement.
15    KeyboardFocus,
16    /// Docking or overlay preview tied to a semantic target.
17    VisualAffordancePreview,
18    /// Lightweight hover, guide, or presence affordance.
19    AffordancePresence,
20    /// Committed layout change such as insert, remove, collapse, or expand.
21    CommittedLayout,
22    /// Continuity motion for retargeted pane, divider, or zoom transitions.
23    Continuity,
24    /// Decorative motion that does not communicate layout or target semantics.
25    Decorative,
26}
27
28impl MotionPolicyContext {
29    /// Returns whether spatial motion is allowed in this context.
30    pub const fn allows_spatial_motion(self) -> bool {
31        !matches!(self, Self::PointerDrag | Self::KeyboardFocus)
32    }
33
34    /// Returns whether this context may exceed the routine UI duration budget.
35    pub const fn allows_extended_duration(self) -> bool {
36        matches!(self, Self::Continuity)
37    }
38}
39
40/// Relationship between a preview sample and the current semantic target.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum MotionPreviewTargetPolicy {
43    /// The motion is not a preview transition.
44    NotPreview,
45    /// Preview motion stays within the same stable semantic identity.
46    SameIdentity,
47    /// Preview motion crosses unrelated semantic identities.
48    UnrelatedIdentity,
49}
50
51/// Deterministic policy issue reported by the motion validator.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum MotionPolicyIssue {
54    /// Spatial motion was requested in a high-frequency context.
55    SpatialMotionForbidden,
56    /// Routine UI motion exceeded the duration budget.
57    DurationOverBudget,
58    /// Review-facing bounce exceeded the professional UI threshold.
59    ExcessiveBounce,
60    /// Reduced motion does not preserve final semantic state.
61    MissingReducedMotionFinalState,
62    /// Preview geometry interpolates across unrelated semantic targets.
63    UnrelatedTargetPreviewInterpolation,
64}
65
66/// Input to the renderer-neutral motion policy validator.
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub struct MotionPolicyInput {
69    context: MotionPolicyContext,
70    model: MotionModel,
71    spatial_motion: bool,
72    reduced_motion_final_state: bool,
73    preview_target: MotionPreviewTargetPolicy,
74    reported_bounce: Option<f32>,
75}
76
77impl MotionPolicyInput {
78    /// Creates policy input for a context and motion model.
79    pub const fn new(context: MotionPolicyContext, model: MotionModel) -> Self {
80        Self {
81            context,
82            model,
83            spatial_motion: false,
84            reduced_motion_final_state: false,
85            preview_target: MotionPreviewTargetPolicy::NotPreview,
86            reported_bounce: None,
87        }
88    }
89
90    /// Returns the policy context.
91    pub const fn context(self) -> MotionPolicyContext {
92        self.context
93    }
94
95    /// Returns the motion model being validated.
96    pub const fn model(self) -> MotionModel {
97        self.model
98    }
99
100    /// Returns whether spatial movement is involved.
101    pub const fn spatial_motion(self) -> bool {
102        self.spatial_motion
103    }
104
105    /// Returns whether reduced motion preserves final semantic state.
106    pub const fn reduced_motion_final_state(self) -> bool {
107        self.reduced_motion_final_state
108    }
109
110    /// Returns the preview target relationship.
111    pub const fn preview_target(self) -> MotionPreviewTargetPolicy {
112        self.preview_target
113    }
114
115    /// Returns an explicit review-facing bounce override.
116    pub const fn reported_bounce(self) -> Option<f32> {
117        self.reported_bounce
118    }
119
120    /// Returns a copy with spatial-motion participation set.
121    pub const fn with_spatial_motion(mut self, spatial_motion: bool) -> Self {
122        self.spatial_motion = spatial_motion;
123        self
124    }
125
126    /// Returns a copy with reduced-motion final-state coverage set.
127    pub const fn with_reduced_motion_final_state(
128        mut self,
129        reduced_motion_final_state: bool,
130    ) -> Self {
131        self.reduced_motion_final_state = reduced_motion_final_state;
132        self
133    }
134
135    /// Returns a copy with preview target relationship set.
136    pub const fn with_preview_target(mut self, preview_target: MotionPreviewTargetPolicy) -> Self {
137        self.preview_target = preview_target;
138        self
139    }
140
141    /// Returns a copy with an explicit review-facing bounce value.
142    pub const fn with_reported_bounce(mut self, bounce: f32) -> Self {
143        self.reported_bounce = Some(bounce);
144        self
145    }
146}
147
148/// Result of validating motion policy.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct MotionPolicyReport {
151    issues: Vec<MotionPolicyIssue>,
152}
153
154impl MotionPolicyReport {
155    /// Creates a policy report from issues.
156    pub fn new(issues: Vec<MotionPolicyIssue>) -> Self {
157        Self { issues }
158    }
159
160    /// Returns all policy issues.
161    pub fn issues(&self) -> &[MotionPolicyIssue] {
162        &self.issues
163    }
164
165    /// Returns whether the policy input passed.
166    pub fn is_ok(&self) -> bool {
167        self.issues.is_empty()
168    }
169
170    /// Returns whether the report contains an issue.
171    pub fn has_issue(&self, issue: MotionPolicyIssue) -> bool {
172        self.issues.contains(&issue)
173    }
174}
175
176/// Validates renderer-neutral motion policy.
177pub fn validate_motion_policy(input: MotionPolicyInput) -> MotionPolicyReport {
178    let mut issues = Vec::new();
179
180    if input.spatial_motion() && !input.context().allows_spatial_motion() {
181        issues.push(MotionPolicyIssue::SpatialMotionForbidden);
182    }
183
184    if !input.context().allows_extended_duration()
185        && model_review_duration(input.model()) > MOTION_POLICY_MAX_UI_DURATION
186    {
187        issues.push(MotionPolicyIssue::DurationOverBudget);
188    }
189
190    if input
191        .reported_bounce()
192        .unwrap_or_else(|| model_bounce(input.model()))
193        > MotionSpringPhysics::MAX_REVIEWABLE_BOUNCE
194    {
195        issues.push(MotionPolicyIssue::ExcessiveBounce);
196    }
197
198    if !input.reduced_motion_final_state() {
199        issues.push(MotionPolicyIssue::MissingReducedMotionFinalState);
200    }
201
202    if input.spatial_motion()
203        && matches!(
204            input.preview_target(),
205            MotionPreviewTargetPolicy::UnrelatedIdentity
206        )
207    {
208        issues.push(MotionPolicyIssue::UnrelatedTargetPreviewInterpolation);
209    }
210
211    MotionPolicyReport::new(issues)
212}
213
214fn model_review_duration(model: MotionModel) -> Duration {
215    match model {
216        MotionModel::Timeline(spec) => spec.duration().as_duration(),
217        MotionModel::Spring(spec) => spec.physics().review_duration(),
218    }
219}
220
221fn model_bounce(model: MotionModel) -> f32 {
222    match model {
223        MotionModel::Timeline(_) => 0.0,
224        MotionModel::Spring(spec) => spec.physics().bounce(),
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::{
232        MotionDuration, MotionEasing, MotionModel, MotionPreference, MotionSpec, MotionSpringSpec,
233    };
234    use std::time::Duration;
235
236    #[test]
237    fn committed_layout_motion_under_budget_passes_policy() {
238        let input = MotionPolicyInput::new(
239            MotionPolicyContext::CommittedLayout,
240            MotionModel::timeline(MotionSpec::committed_layout(MotionPreference::Animated)),
241        )
242        .with_spatial_motion(true)
243        .with_reduced_motion_final_state(true);
244
245        assert!(validate_motion_policy(input).is_ok());
246    }
247
248    #[test]
249    fn pointer_drag_spatial_motion_is_rejected() {
250        let input = MotionPolicyInput::new(
251            MotionPolicyContext::PointerDrag,
252            MotionModel::spring(MotionSpringSpec::layout(MotionPreference::Animated)),
253        )
254        .with_spatial_motion(true)
255        .with_reduced_motion_final_state(true);
256
257        let report = validate_motion_policy(input);
258        assert!(report.has_issue(MotionPolicyIssue::SpatialMotionForbidden));
259    }
260
261    #[test]
262    fn keyboard_focus_spatial_motion_is_rejected() {
263        let input = MotionPolicyInput::new(
264            MotionPolicyContext::KeyboardFocus,
265            MotionModel::timeline(MotionSpec::continuity(MotionPreference::Animated)),
266        )
267        .with_spatial_motion(true)
268        .with_reduced_motion_final_state(true);
269
270        let report = validate_motion_policy(input);
271        assert!(report.has_issue(MotionPolicyIssue::SpatialMotionForbidden));
272    }
273
274    #[test]
275    fn overlong_ui_motion_without_continuity_reason_is_rejected() {
276        let input = MotionPolicyInput::new(
277            MotionPolicyContext::CommittedLayout,
278            MotionModel::timeline(MotionSpec::new(
279                MotionPreference::Animated,
280                MotionDuration::Custom(Duration::from_millis(420)),
281                MotionEasing::EaseOut,
282            )),
283        )
284        .with_spatial_motion(true)
285        .with_reduced_motion_final_state(true);
286
287        let report = validate_motion_policy(input);
288        assert!(report.has_issue(MotionPolicyIssue::DurationOverBudget));
289    }
290
291    #[test]
292    fn excessive_bounce_is_rejected() {
293        let input = MotionPolicyInput::new(
294            MotionPolicyContext::CommittedLayout,
295            MotionModel::spring(MotionSpringSpec::layout(MotionPreference::Animated)),
296        )
297        .with_spatial_motion(true)
298        .with_reported_bounce(0.8)
299        .with_reduced_motion_final_state(true);
300
301        let report = validate_motion_policy(input);
302        assert!(report.has_issue(MotionPolicyIssue::ExcessiveBounce));
303    }
304
305    #[test]
306    fn unrelated_target_preview_interpolation_is_rejected() {
307        let input = MotionPolicyInput::new(
308            MotionPolicyContext::VisualAffordancePreview,
309            MotionModel::spring(MotionSpringSpec::affordance(MotionPreference::Animated)),
310        )
311        .with_spatial_motion(true)
312        .with_preview_target(MotionPreviewTargetPolicy::UnrelatedIdentity)
313        .with_reduced_motion_final_state(true);
314
315        let report = validate_motion_policy(input);
316        assert!(report.has_issue(MotionPolicyIssue::UnrelatedTargetPreviewInterpolation));
317    }
318
319    #[test]
320    fn reduced_motion_final_semantics_without_spatial_motion_pass() {
321        let input = MotionPolicyInput::new(
322            MotionPolicyContext::CommittedLayout,
323            MotionModel::spring(MotionSpringSpec::layout(MotionPreference::Reduced)),
324        )
325        .with_spatial_motion(false)
326        .with_reduced_motion_final_state(true);
327
328        assert!(validate_motion_policy(input).is_ok());
329    }
330}