Files
Ely-GPUI-Components/src/calendar/hours.rs
T
ZacharyZhang-NY 7e3f99f278 Calendar · Time grid: week and day views, drag to make and to resize
TimeGrid sets days side by side under a column of hours. Each event is
a card by its hours, clipped to each day it touches, and events that
overlap share the width in lanes. A drag on empty time makes an event
by quarter hours through forms::pen; a drag on an event's lower edge,
on the day it ends, moves its end, each drag checked against its
owner. The grid opens at half past seven, or an hour and a half
before now when today is shown and that is later.

CurrentTimeIndicator marks the minute now on today and moves with the
clock. TimezoneOverlay adds another zone's hours beside the grid's.
AllDayRow lays whole days across their columns in lanes.
CalendarWeekView and CalendarDayView are one view of seven days or
one: a header that steps and comes back to today, the days' names
stacked so they fit narrow columns, the whole days, then the hours.
2026-09-27 03:13:38 -04:00

205 lines
6.4 KiB
Rust

use jiff::{SignedDuration, Timestamp, civil::Date, tz::TimeZone};
use super::event::{Event, When};
/// Minutes in a day.
pub(crate) const DAY: i64 = 24 * 60;
/// The step a drag snaps to, in minutes.
pub(crate) const SNAP: i64 = 15;
/// An event's stretch of `day` in `zone`, in minutes from midnight: clipped to the day, as a night that runs past midnight shows on both.
pub(crate) fn minutes(event: &Event, day: Date, zone: &TimeZone) -> Option<(i64, i64)> {
let When::Timed { start, end } = event.when else {
return None;
};
let midnight = day
.to_zoned(zone.clone())
.expect("the zone holds the day")
.timestamp();
let from = start.duration_since(midnight).as_mins();
let to = end.duration_since(midnight).as_mins();
(from < DAY && to > 0).then(|| (from.max(0), to.min(DAY)))
}
/// A block in a day column: which event, its minutes, and its lane among the lanes of the blocks it overlaps.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Block {
pub event: usize,
pub from: i64,
pub to: i64,
pub lane: usize,
pub lanes: usize,
}
/// A day's timed events as blocks: those that overlap share the width in lanes, earliest first, each in the first lane free.
pub(crate) fn blocks(events: &[Event], day: Date, zone: &TimeZone) -> Vec<Block> {
let mut spans: Vec<(usize, i64, i64)> = events
.iter()
.enumerate()
.filter_map(|(ix, event)| minutes(event, day, zone).map(|(from, to)| (ix, from, to)))
.collect();
spans.sort_by_key(|&(ix, from, to)| (from, -to, ix));
let mut blocks: Vec<Block> = Vec::with_capacity(spans.len());
let mut cluster = 0;
let mut reach = i64::MIN;
let mut ends: Vec<i64> = Vec::new();
for (event, from, to) in spans {
if from >= reach {
close(&mut blocks[cluster..]);
cluster = blocks.len();
ends.clear();
}
let lane = ends
.iter()
.position(|end| *end <= from)
.unwrap_or(ends.len());
match ends.get_mut(lane) {
Some(end) => *end = to,
None => ends.push(to),
}
reach = reach.max(to);
blocks.push(Block {
event,
from,
to,
lane,
lanes: 0,
});
}
close(&mut blocks[cluster..]);
blocks
}
/// Gives each block of a cluster the cluster's count of lanes.
fn close(cluster: &mut [Block]) {
let lanes = cluster
.iter()
.map(|block| block.lane + 1)
.max()
.unwrap_or(0);
for block in cluster {
block.lanes = lanes;
}
}
/// `minutes` to the nearest step of `SNAP`, inside the day.
pub(crate) fn snap(minutes: f32) -> i64 {
let step = SNAP as f32;
((minutes / step).round() as i64 * SNAP).clamp(0, DAY)
}
/// The quarter hours a drag from `from` to `to` covers, in pixels down a column where an hour is `hour` tall; none under a quarter.
pub(crate) fn dragged(from: f32, to: f32, hour: f32) -> Option<(i64, i64)> {
let (top, bottom) = (from.min(to), from.max(to));
let (start, end) = (snap(top / hour * 60.0), snap(bottom / hour * 60.0));
(end - start >= SNAP).then_some((start, end))
}
/// The moment `minutes` after `day`'s midnight in `zone`.
pub(crate) fn moment(day: Date, minutes: i64, zone: &TimeZone) -> Timestamp {
let midnight = day
.to_zoned(zone.clone())
.expect("the zone holds the day")
.timestamp();
midnight + SignedDuration::from_mins(minutes)
}
#[cfg(test)]
mod tests {
use jiff::{Timestamp, civil::date, tz::TimeZone};
use super::{Block, blocks, dragged, minutes, moment, snap};
use crate::calendar::Event;
fn at(day: i8, hour: i8, minute: i8) -> Timestamp {
date(2026, 9, day)
.at(hour, minute, 0, 0)
.to_zoned(TimeZone::UTC)
.expect("a UTC time")
.timestamp()
}
fn block(event: usize, from: i64, to: i64, lane: usize, lanes: usize) -> Block {
Block {
event,
from,
to,
lane,
lanes,
}
}
#[test]
fn a_night_shows_on_both_days_it_touches() {
let night = Event::timed("night", "Night", at(22, 21, 0), at(23, 2, 30), 0);
let utc = &TimeZone::UTC;
assert_eq!(
minutes(&night, date(2026, 9, 22), utc),
Some((21 * 60, 24 * 60))
);
assert_eq!(minutes(&night, date(2026, 9, 23), utc), Some((0, 150)));
assert_eq!(minutes(&night, date(2026, 9, 24), utc), None);
let all = Event::all_day("all", "All", date(2026, 9, 22), date(2026, 9, 22), 0);
assert_eq!(
minutes(&all, date(2026, 9, 22), utc),
None,
"whole days sit above"
);
}
#[test]
fn overlapping_blocks_share_the_width_and_the_rest_take_it_all() {
let events = [
Event::timed("a", "A", at(22, 9, 0), at(22, 10, 0), 0),
Event::timed("b", "B", at(22, 9, 30), at(22, 11, 0), 0),
Event::timed("c", "C", at(22, 10, 0), at(22, 10, 30), 0),
Event::timed("d", "D", at(22, 13, 0), at(22, 14, 0), 0),
];
assert_eq!(
blocks(&events, date(2026, 9, 22), &TimeZone::UTC),
[
block(0, 540, 600, 0, 2),
block(1, 570, 660, 1, 2),
block(2, 600, 630, 0, 2),
block(3, 780, 840, 0, 1),
],
"C takes A's lane once A ends; D stands alone"
);
}
#[test]
fn a_drag_snaps_to_quarters_inside_the_day() {
assert_eq!(snap(7.0), 0);
assert_eq!(snap(8.0), 15);
assert_eq!(snap(9.0 * 60.0 + 22.0), 9 * 60 + 15);
assert_eq!(snap(-30.0), 0);
assert_eq!(snap(25.0 * 60.0), 24 * 60);
assert_eq!(
moment(date(2026, 9, 22), 9 * 60 + 15, &TimeZone::UTC),
at(22, 9, 15)
);
}
#[test]
fn a_drag_either_way_covers_its_quarters() {
let hour = 48.0;
assert_eq!(dragged(9.0 * hour, 10.5 * hour, hour), Some((540, 630)));
assert_eq!(
dragged(10.5 * hour, 9.0 * hour, hour),
Some((540, 630)),
"upward"
);
assert_eq!(
dragged(9.0 * hour, 9.1 * hour, hour),
None,
"under a quarter"
);
assert_eq!(
dragged(9.0 * hour, 9.25 * hour, hour),
Some((540, 555)),
"a quarter"
);
}
}