open_gpui_motion/
policy.rs1use crate::spring::{MotionModel, MotionSpringPhysics};
4use std::time::Duration;
5
6pub const MOTION_POLICY_MAX_UI_DURATION: Duration = Duration::from_millis(300);
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum MotionPolicyContext {
12 PointerDrag,
14 KeyboardFocus,
16 VisualAffordancePreview,
18 AffordancePresence,
20 CommittedLayout,
22 Continuity,
24 Decorative,
26}
27
28impl MotionPolicyContext {
29 pub const fn allows_spatial_motion(self) -> bool {
31 !matches!(self, Self::PointerDrag | Self::KeyboardFocus)
32 }
33
34 pub const fn allows_extended_duration(self) -> bool {
36 matches!(self, Self::Continuity)
37 }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum MotionPreviewTargetPolicy {
43 NotPreview,
45 SameIdentity,
47 UnrelatedIdentity,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum MotionPolicyIssue {
54 SpatialMotionForbidden,
56 DurationOverBudget,
58 ExcessiveBounce,
60 MissingReducedMotionFinalState,
62 UnrelatedTargetPreviewInterpolation,
64}
65
66#[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 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 pub const fn context(self) -> MotionPolicyContext {
92 self.context
93 }
94
95 pub const fn model(self) -> MotionModel {
97 self.model
98 }
99
100 pub const fn spatial_motion(self) -> bool {
102 self.spatial_motion
103 }
104
105 pub const fn reduced_motion_final_state(self) -> bool {
107 self.reduced_motion_final_state
108 }
109
110 pub const fn preview_target(self) -> MotionPreviewTargetPolicy {
112 self.preview_target
113 }
114
115 pub const fn reported_bounce(self) -> Option<f32> {
117 self.reported_bounce
118 }
119
120 pub const fn with_spatial_motion(mut self, spatial_motion: bool) -> Self {
122 self.spatial_motion = spatial_motion;
123 self
124 }
125
126 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 pub const fn with_preview_target(mut self, preview_target: MotionPreviewTargetPolicy) -> Self {
137 self.preview_target = preview_target;
138 self
139 }
140
141 pub const fn with_reported_bounce(mut self, bounce: f32) -> Self {
143 self.reported_bounce = Some(bounce);
144 self
145 }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct MotionPolicyReport {
151 issues: Vec<MotionPolicyIssue>,
152}
153
154impl MotionPolicyReport {
155 pub fn new(issues: Vec<MotionPolicyIssue>) -> Self {
157 Self { issues }
158 }
159
160 pub fn issues(&self) -> &[MotionPolicyIssue] {
162 &self.issues
163 }
164
165 pub fn is_ok(&self) -> bool {
167 self.issues.is_empty()
168 }
169
170 pub fn has_issue(&self, issue: MotionPolicyIssue) -> bool {
172 self.issues.contains(&issue)
173 }
174}
175
176pub 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}