Skip to main content

ferroui_themes_material/
time_picker_state.rs

1// Copyright 2023 The Android Open Source Project (reference behavior).
2// Copyright 2026 FerroUI contributors (Rust adaptation).
3// SPDX-License-Identifier: Apache-2.0
4//! Geometry and input rules translated from AndroidX Material3 TimePicker.kt at
5//! e4d933c98df7baa7ea2b83eea6e086915961e0ff (1.5.0-beta02).
6
7use ferroui_base::layout::Orientation;
8use ferroui_base::{Point, Rect, Size};
9use std::f64::consts::{FRAC_PI_2, PI, TAU};
10
11#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
12pub enum MaterialTimeSelection {
13    #[default]
14    Hour,
15    Minute,
16}
17ferroui_base::ferro_markup_enum!(MaterialTimeSelection { Hour, Minute }, { namespace: "FerroUI.Themes.Material" });
18
19#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
20pub enum MaterialTimeDisplayMode {
21    #[default]
22    Dial,
23    Input,
24}
25ferroui_base::ferro_markup_enum!(MaterialTimeDisplayMode { Dial, Input }, { namespace: "FerroUI.Themes.Material" });
26
27pub(crate) fn display_hour(hour: i32, is_24_hour: bool) -> i32 {
28    if is_24_hour {
29        hour
30    } else if hour % 12 == 0 {
31        12
32    } else {
33        hour % 12
34    }
35}
36
37pub(crate) fn angle_for(hour: i32, minute: i32, selection: MaterialTimeSelection) -> f64 {
38    TAU * match selection {
39        MaterialTimeSelection::Hour => (hour % 12) as f64 / 12.0,
40        MaterialTimeSelection::Minute => minute as f64 / 60.0,
41    } - FRAC_PI_2
42}
43
44/// Native atan convention, including the exact antipodal choice of the negative half turn.
45pub(crate) fn shortest_target(current: f64, target: f64) -> f64 {
46    let difference = (current - target).rem_euclid(TAU);
47    current
48        - if difference > PI {
49            difference - TAU
50        } else {
51            difference
52        }
53}
54
55#[derive(Clone, Copy, Debug)]
56pub(crate) struct DialGeometry {
57    pub diameter: f64,
58    pub center: Point,
59    pub scale: f64,
60}
61impl DialGeometry {
62    pub fn new(size: Size) -> Self {
63        let diameter = size.width.min(size.height).max(0.0);
64        Self {
65            diameter,
66            center: Point::new(size.width / 2.0, size.height / 2.0),
67            scale: diameter / 256.0,
68        }
69    }
70    pub fn center_at(self, angle: f64, inner: bool) -> Point {
71        let radius = if inner { 69.0 } else { 101.0 } * self.scale;
72        Point::new(
73            self.center.x + radius * angle.cos(),
74            self.center.y + radius * angle.sin(),
75        )
76    }
77    pub fn number_rect(self, index: usize) -> Rect {
78        let p = self.center_at(index as f64 * TAU / 12.0 - FRAC_PI_2, index >= 12);
79        Rect::new(p.x - 24.0, p.y - 24.0, 48.0, 48.0)
80    }
81    pub fn value_at(
82        self,
83        position: Point,
84        selection: MaterialTimeSelection,
85        is_24_hour: bool,
86        is_pm: bool,
87        tapping: bool,
88    ) -> (i32, f64) {
89        let dx = position.x - self.center.x;
90        let dy = position.y - self.center.y;
91        let angle = dy.atan2(dx);
92        let fraction = (angle + FRAC_PI_2).rem_euclid(TAU) / TAU;
93        match selection {
94            MaterialTimeSelection::Hour => {
95                let hour = (fraction * 12.0).round() as i32 % 12;
96                let pm = if is_24_hour {
97                    dx.hypot(dy) < 74.0 * self.scale
98                } else {
99                    is_pm
100                };
101                (hour + if pm { 12 } else { 0 }, angle)
102            }
103            MaterialTimeSelection::Minute => {
104                let minute = if tapping {
105                    ((fraction * 12.0).round() as i32 * 5) % 60
106                } else {
107                    (fraction * 60.0).round() as i32 % 60
108                };
109                (minute, angle)
110            }
111        }
112    }
113}
114
115/// Empty input changes the selected unit to zero; invalid ranges keep the last valid time.
116/// Syntax validation is separate so a temporarily invalid 99 remains editable and announced.
117pub(crate) fn parse_input(
118    text: &str,
119    selection: MaterialTimeSelection,
120    is_24_hour: bool,
121    is_pm: bool,
122) -> Result<Option<i32>, ()> {
123    if text.len() > 2 || !text.bytes().all(|c| c.is_ascii_digit()) {
124        return Err(());
125    }
126    let value = if text.is_empty() {
127        0
128    } else {
129        text.parse::<i32>().map_err(|_| ())?
130    };
131    Ok(match selection {
132        MaterialTimeSelection::Minute => (value <= 59).then_some(value),
133        MaterialTimeSelection::Hour if is_24_hour => (value <= 23).then_some(value),
134        MaterialTimeSelection::Hour => {
135            (value <= 12).then_some(value % 12 + if is_pm { 12 } else { 0 })
136        }
137    })
138}
139
140#[derive(Clone, Copy, Debug)]
141pub(crate) struct TimePickerMetrics {
142    pub field_width: f64,
143    pub field_height: f64,
144    pub separator: f64,
145    pub period_width: f64,
146    pub period_gap: f64,
147    pub period_inner_gap: f64,
148    pub dial_size: f64,
149    pub dial_gap: f64,
150    pub bottom: f64,
151    pub support_height: f64,
152    pub horizontal: bool,
153    pub input: bool,
154}
155impl TimePickerMetrics {
156    pub fn new(
157        vibrant: bool,
158        is_24_hour: bool,
159        mode: MaterialTimeDisplayMode,
160        orientation: Orientation,
161        available_height: f64,
162    ) -> Self {
163        let input = mode == MaterialTimeDisplayMode::Input;
164        let horizontal = !input && orientation == Orientation::Horizontal;
165        Self {
166            // ClockDisplayNumbers currently uses the classic 96dp width in both formats;
167            // the generated 114dp vertical-24h token is not used by the pinned component.
168            field_width: if vibrant {
169                if is_24_hour && !input && !horizontal {
170                    132.0
171                } else {
172                    100.0
173                }
174            } else {
175                96.0
176            },
177            field_height: if vibrant {
178                120.0
179            } else if input {
180                72.0
181            } else {
182                80.0
183            },
184            separator: if vibrant { 16.0 } else { 24.0 },
185            period_width: if vibrant { 56.0 } else { 52.0 },
186            period_gap: if horizontal {
187                16.0
188            } else if vibrant {
189                if input { 8.0 } else { 16.0 }
190            } else {
191                4.0
192            },
193            period_inner_gap: if vibrant {
194                if horizontal { 16.0 } else { 8.0 }
195            } else {
196                4.0
197            },
198            dial_size: if !horizontal || vibrant || available_height >= 384.0 {
199                256.0
200            } else if available_height >= 330.0 {
201                238.0
202            } else {
203                200.0
204            },
205            dial_gap: if vibrant {
206                if horizontal { 52.0 } else { 12.0 }
207            } else {
208                36.0
209            },
210            bottom: if !vibrant && !horizontal && !input {
211                24.0
212            } else {
213                0.0
214            },
215            support_height: if input && !vibrant { 39.0 } else { 0.0 },
216            horizontal,
217            input,
218        }
219    }
220    pub fn fields_width(self) -> f64 {
221        2.0 * self.field_width + self.separator
222    }
223    pub fn display_size(self, is_24_hour: bool, vibrant: bool) -> Size {
224        let mut size = Size::new(self.fields_width(), self.field_height + self.support_height);
225        if !is_24_hour {
226            if self.horizontal {
227                size.height += self.period_gap + if vibrant { 40.0 } else { 38.0 };
228            } else {
229                size.width += self.period_gap + self.period_width;
230            }
231        }
232        size
233    }
234    pub fn desired_size(self, is_24_hour: bool, vibrant: bool) -> Size {
235        let display = self.display_size(is_24_hour, vibrant);
236        if self.input {
237            display
238        } else if self.horizontal {
239            Size::new(
240                display.width + self.dial_gap + self.dial_size,
241                display.height.max(self.dial_size),
242            )
243        } else {
244            Size::new(
245                display.width.max(self.dial_size),
246                display.height + self.dial_gap + self.dial_size + self.bottom,
247            )
248        }
249    }
250}