Skip to main content

ravenrs_widgets/scheduler/
time.rs

1//! The time axis and how events share a row, with no UI.
2
3use crate::date::Date;
4
5const MINUTES_PER_DAY: i64 = 24 * 60;
6
7/// A moment on a calendar, to the minute, with no time zone.
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
9#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
10pub struct TimePoint(i64);
11
12#[cfg_attr(feature = "live", ravenrs_live::expose)]
13impl TimePoint {
14    /// Minutes since the start of 1970-01-01.
15    pub const fn from_minutes(minutes: i64) -> Self {
16        Self(minutes)
17    }
18
19    /// The start of this day.
20    pub fn from_date(date: Date) -> Self {
21        Self(date.to_days() * MINUTES_PER_DAY)
22    }
23
24    /// This hour and minute of this day.
25    pub fn at(date: Date, hour: u32, minute: u32) -> Self {
26        Self(date.to_days() * MINUTES_PER_DAY + i64::from(hour) * 60 + i64::from(minute))
27    }
28
29    /// Minutes since the start of 1970-01-01.
30    pub const fn minutes(self) -> i64 {
31        self.0
32    }
33
34    /// Its day.
35    pub fn date(self) -> Date {
36        Date::from_days(self.0.div_euclid(MINUTES_PER_DAY))
37    }
38
39    /// Hour and minute of its day.
40    pub fn hour_minute(self) -> (u32, u32) {
41        let minutes = self.0.rem_euclid(MINUTES_PER_DAY);
42        ((minutes / 60) as u32, (minutes % 60) as u32)
43    }
44
45    /// `minutes` later (earlier when negative).
46    pub const fn plus_minutes(self, minutes: i64) -> Self {
47        Self(self.0 + minutes)
48    }
49
50    /// `days` later (earlier when negative).
51    pub const fn plus_days(self, days: i64) -> Self {
52        Self(self.0 + days * MINUTES_PER_DAY)
53    }
54}
55
56/// What one tick of a [`TimeAxis`] is.
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
58#[non_exhaustive]
59#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
60pub enum TimeScale {
61    /// An hour, under its day.
62    Hours,
63    /// A day, under its month.
64    #[default]
65    Days,
66    /// A week, under its month.
67    Weeks,
68    /// A month, under its year.
69    Months,
70}
71
72/// From far to near, as zooming in goes: a scale and the width of a tick.
73const ZOOM_LEVELS: [(TimeScale, f32); 8] = [
74    (TimeScale::Months, 56.0),
75    (TimeScale::Months, 110.0),
76    (TimeScale::Weeks, 60.0),
77    (TimeScale::Weeks, 110.0),
78    (TimeScale::Days, 40.0),
79    (TimeScale::Days, 80.0),
80    (TimeScale::Hours, 36.0),
81    (TimeScale::Hours, 72.0),
82];
83
84/// The stretch of time a [`Scheduler`](crate::Scheduler) shows, how wide it is and how it is marked.
85#[derive(Clone, Copy, Debug, PartialEq)]
86#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
87pub struct TimeAxis {
88    /// Where it begins.
89    pub start: TimePoint,
90    /// Where it ends.
91    pub end: TimePoint,
92    /// What a tick is.
93    pub scale: TimeScale,
94    /// The width of a tick, in logical pixels. A month is 30 days wide, so all time runs at one speed.
95    pub tick_width: f32,
96}
97
98/// A marked stretch of the axis, in pixels from its start.
99#[derive(Clone, Debug, PartialEq)]
100pub(crate) struct Tick {
101    pub x: f32,
102    pub width: f32,
103    pub start: TimePoint,
104}
105
106#[cfg_attr(feature = "live", ravenrs_live::expose)]
107impl TimeAxis {
108    /// `count` days from `from`, a tick per day.
109    pub fn days(from: Date, count: u32) -> Self {
110        let start = TimePoint::from_date(from);
111        Self {
112            start,
113            end: start.plus_days(i64::from(count.max(1))),
114            scale: TimeScale::Days,
115            tick_width: 40.0,
116        }
117    }
118
119    /// `count` weeks from the week of `from`, a tick per week.
120    pub fn weeks(from: Date, count: u32) -> Self {
121        let start = TimePoint::from_date(from.add_days(-i32::from(from.weekday())));
122        let end = start.plus_days(7 * i64::from(count.max(1)));
123        Self { start, end, scale: TimeScale::Weeks, tick_width: 60.0 }
124    }
125
126    /// `count` months from the month of `from`, a tick per month.
127    pub fn months(from: Date, count: u32) -> Self {
128        let first = from.first_of_month();
129        let last = first.add_months(i32::try_from(count.max(1)).unwrap_or(1));
130        Self {
131            start: TimePoint::from_date(first),
132            end: TimePoint::from_date(last),
133            scale: TimeScale::Months,
134            tick_width: 56.0,
135        }
136    }
137
138    /// One day, a tick per hour.
139    pub fn hours(day: Date) -> Self {
140        let start = TimePoint::from_date(day);
141        Self { start, end: start.plus_days(1), scale: TimeScale::Hours, tick_width: 36.0 }
142    }
143
144    /// The same stretch of time, one step nearer. `None` when it cannot go nearer.
145    pub fn zoomed_in(&self) -> Option<Self> {
146        self.zoomed(1)
147    }
148
149    /// The same stretch of time, one step farther. `None` when it cannot go farther.
150    pub fn zoomed_out(&self) -> Option<Self> {
151        self.zoomed(-1)
152    }
153
154    fn zoomed(&self, step: i32) -> Option<Self> {
155        let here = ZOOM_LEVELS
156            .iter()
157            .rposition(|(scale, width)| *scale == self.scale && *width <= self.tick_width)
158            .or_else(|| ZOOM_LEVELS.iter().position(|(scale, _)| *scale == self.scale))?;
159        let there = i32::try_from(here).ok()? + step;
160        let (scale, tick_width) = *ZOOM_LEVELS.get(usize::try_from(there).ok()?)?;
161        Some(Self { scale, tick_width, ..*self })
162    }
163
164    /// The same length of time, moved by a whole `steps` of its length.
165    pub fn shifted(&self, steps: i64) -> Self {
166        let length = self.end.minutes() - self.start.minutes();
167        match self.scale {
168            TimeScale::Months => {
169                let months = (length as f64 / (30.44 * MINUTES_PER_DAY as f64)).round() as i32;
170                let by = i32::try_from(steps).unwrap_or(0) * months.max(1);
171                Self {
172                    start: TimePoint::from_date(self.start.date().add_months(by)),
173                    end: TimePoint::from_date(self.end.date().add_months(by)),
174                    ..*self
175                }
176            }
177            _ => Self {
178                start: self.start.plus_minutes(length * steps),
179                end: self.end.plus_minutes(length * steps),
180                ..*self
181            },
182        }
183    }
184
185    fn pixels_per_minute(&self) -> f64 {
186        let tick_minutes = match self.scale {
187            TimeScale::Hours => 60.0,
188            TimeScale::Days => MINUTES_PER_DAY as f64,
189            TimeScale::Weeks => 7.0 * MINUTES_PER_DAY as f64,
190            TimeScale::Months => 30.0 * MINUTES_PER_DAY as f64,
191        };
192        f64::from(self.tick_width.max(1.0)) / tick_minutes
193    }
194
195    /// The whole width, in logical pixels.
196    pub fn width(&self) -> f32 {
197        self.x_of(self.end).max(0.0)
198    }
199
200    /// Where a moment falls, in logical pixels from the start.
201    pub fn x_of(&self, time: TimePoint) -> f32 {
202        ((time.minutes() - self.start.minutes()) as f64 * self.pixels_per_minute()) as f32
203    }
204
205    /// The moment at this position.
206    pub fn time_at(&self, x: f32) -> TimePoint {
207        let minutes = (f64::from(x) / self.pixels_per_minute()).round() as i64;
208        self.start.plus_minutes(minutes)
209    }
210
211    /// The smallest step an event moves by: a quarter of an hour on the hour
212    /// scale, a day on the others.
213    pub fn snap_minutes(&self) -> i64 {
214        match self.scale {
215            TimeScale::Hours => 15,
216            _ => MINUTES_PER_DAY,
217        }
218    }
219
220    /// The nearest moment an event can start or end at.
221    pub fn snap(&self, time: TimePoint) -> TimePoint {
222        let step = self.snap_minutes();
223        TimePoint::from_minutes((time.minutes() + step / 2).div_euclid(step) * step)
224    }
225
226    fn next_tick(&self, tick: TimePoint) -> TimePoint {
227        match self.scale {
228            TimeScale::Hours => tick.plus_minutes(60),
229            TimeScale::Days => tick.plus_days(1),
230            TimeScale::Weeks => tick.plus_days(7),
231            TimeScale::Months => TimePoint::from_date(tick.date().first_of_month().add_months(1)),
232        }
233    }
234
235    /// The ticks overlapping `from..to` pixels.
236    pub(crate) fn ticks(&self, from: f32, to: f32) -> Vec<Tick> {
237        let mut ticks = Vec::new();
238        let mut tick = self.start;
239        // Every step moves forward, so this stops at the end of the axis.
240        while tick < self.end {
241            let next = self.next_tick(tick).min(self.end);
242            let (x, end) = (self.x_of(tick), self.x_of(next));
243            if end > from && x < to {
244                ticks.push(Tick { x, width: end - x, start: tick });
245            }
246            if x >= to {
247                break;
248            }
249            tick = next;
250        }
251        ticks
252    }
253
254    /// Whether this tick falls on the top row's change: a new day, month or year.
255    pub(crate) fn starts_group(&self, tick: TimePoint, previous: Option<TimePoint>) -> bool {
256        let Some(previous) = previous else { return true };
257        let (now, then) = (tick.date(), previous.date());
258        match self.scale {
259            TimeScale::Hours => now != then,
260            TimeScale::Days | TimeScale::Weeks => now.month() != then.month() || now.year() != then.year(),
261            TimeScale::Months => now.year() != then.year(),
262        }
263    }
264}
265
266/// Places overlapping stretches of time on lanes, the earliest first, so none
267/// overlaps another on its lane. Returns each one's lane, in the order given, and
268/// how many lanes there are; at least one.
269pub(crate) fn assign_lanes(spans: &[(TimePoint, TimePoint)]) -> (Vec<usize>, usize) {
270    let mut order: Vec<usize> = (0..spans.len()).collect();
271    order.sort_by_key(|i| (spans[*i].0, spans[*i].1));
272    let mut lanes = vec![0; spans.len()];
273    let mut ends: Vec<TimePoint> = Vec::new();
274    for i in order {
275        let (start, end) = spans[i];
276        // A moment with no length still takes room on its lane.
277        let end = end.max(start.plus_minutes(1));
278        match ends.iter().position(|free_at| *free_at <= start) {
279            Some(lane) => {
280                ends[lane] = end;
281                lanes[i] = lane;
282            }
283            None => {
284                lanes[i] = ends.len();
285                ends.push(end);
286            }
287        }
288    }
289    (lanes, ends.len().max(1))
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    fn date(y: i32, m: u8, d: u8) -> Date {
297        Date::new(y, m, d).unwrap_or_else(|| panic!("invalid date"))
298    }
299
300    fn at(d: u8, h: u32) -> TimePoint {
301        TimePoint::at(date(2026, 11, d), h, 0)
302    }
303
304    #[test]
305    fn a_moment_knows_its_day_and_hour() {
306        let time = TimePoint::at(date(1969, 12, 31), 23, 30);
307        assert_eq!(time.date(), date(1969, 12, 31), "before 1970 too");
308        assert_eq!(time.hour_minute(), (23, 30));
309    }
310
311    #[test]
312    fn positions_and_moments_are_inverses() {
313        let axis = TimeAxis::days(date(2026, 11, 1), 30);
314        assert_eq!(axis.width(), 30.0 * 40.0);
315        assert_eq!(axis.x_of(at(2, 0)), 40.0);
316        assert_eq!(axis.x_of(at(2, 12)), 60.0, "time runs evenly through a day");
317        assert_eq!(axis.time_at(60.0), at(2, 12));
318    }
319
320    #[test]
321    fn events_snap_to_days_and_to_quarters_of_an_hour() {
322        let days = TimeAxis::days(date(2026, 11, 1), 30);
323        assert_eq!(days.snap(at(3, 11)), at(3, 0));
324        assert_eq!(days.snap(at(3, 13)), at(4, 0));
325        let hours = TimeAxis::hours(date(2026, 11, 3));
326        assert_eq!(
327            hours.snap(TimePoint::at(date(2026, 11, 3), 9, 7)),
328            TimePoint::at(date(2026, 11, 3), 9, 0)
329        );
330        assert_eq!(
331            hours.snap(TimePoint::at(date(2026, 11, 3), 9, 8)),
332            TimePoint::at(date(2026, 11, 3), 9, 15)
333        );
334    }
335
336    #[test]
337    fn months_have_their_own_lengths() {
338        let axis = TimeAxis::months(date(2026, 2, 15), 2);
339        let ticks = axis.ticks(0.0, axis.width());
340        assert_eq!(ticks.len(), 2);
341        assert_eq!(ticks[0].start.date(), date(2026, 2, 1));
342        assert_eq!(ticks[1].start.date(), date(2026, 3, 1));
343        assert!(ticks[0].width < ticks[1].width, "February is shorter than March");
344    }
345
346    #[test]
347    fn only_the_ticks_in_view_are_made() {
348        let axis = TimeAxis::days(date(2026, 1, 1), 365);
349        let ticks = axis.ticks(400.0, 500.0);
350        assert_eq!(ticks.len(), 3, "from the 11th to the 13th");
351        assert_eq!(ticks[0].x, 400.0);
352    }
353
354    #[test]
355    fn weeks_start_on_sunday() {
356        // November 4, 2026 is a Wednesday.
357        let axis = TimeAxis::weeks(date(2026, 11, 4), 2);
358        assert_eq!(axis.start.date(), date(2026, 11, 1));
359        assert_eq!(axis.end.date(), date(2026, 11, 15));
360    }
361
362    #[test]
363    fn zoom_walks_through_the_levels_and_stops_at_the_ends() {
364        let mut axis = TimeAxis::months(date(2026, 1, 1), 12);
365        let range = (axis.start, axis.end);
366        let mut steps = 0;
367        while let Some(nearer) = axis.zoomed_in() {
368            assert!(nearer.width() > axis.width(), "nearer is always wider");
369            assert_eq!((nearer.start, nearer.end), range, "over the same time");
370            axis = nearer;
371            steps += 1;
372        }
373        assert_eq!(steps, ZOOM_LEVELS.len() - 1);
374        assert_eq!(axis.scale, TimeScale::Hours);
375        while let Some(farther) = axis.zoomed_out() {
376            axis = farther;
377        }
378        assert_eq!(axis.scale, TimeScale::Months);
379    }
380
381    #[test]
382    fn an_axis_moves_by_its_own_length() {
383        let days = TimeAxis::days(date(2026, 11, 1), 7).shifted(1);
384        assert_eq!(days.start.date(), date(2026, 11, 8));
385        let months = TimeAxis::months(date(2026, 11, 1), 3).shifted(-1);
386        assert_eq!(months.start.date(), date(2026, 8, 1));
387        assert_eq!(months.end.date(), date(2026, 11, 1));
388    }
389
390    #[test]
391    fn overlapping_events_take_separate_lanes() {
392        let spans = [(at(1, 0), at(3, 0)), (at(2, 0), at(4, 0)), (at(3, 0), at(5, 0)), (at(1, 0), at(6, 0))];
393        let (lanes, count) = assign_lanes(&spans);
394        assert_eq!(count, 3);
395        assert_ne!(lanes[0], lanes[1]);
396        assert_ne!(lanes[0], lanes[3]);
397        assert_eq!(lanes[2], lanes[0], "the third starts as the first ends, so it can reuse its lane");
398    }
399
400    #[test]
401    fn a_row_with_nothing_still_has_a_lane() {
402        assert_eq!(assign_lanes(&[]), (vec![], 1));
403    }
404
405    #[test]
406    fn moments_with_no_length_do_not_land_on_each_other() {
407        let (lanes, count) = assign_lanes(&[(at(1, 0), at(1, 0)), (at(1, 0), at(1, 0))]);
408        assert_eq!(count, 2);
409        assert_ne!(lanes[0], lanes[1]);
410    }
411}