// Names this module's prose links to, resolved for rustdoc. #[allow(unused_imports)] use crate::{Region, Share, Tone}; /// What a [`Track`]'s integers count. /// /// `Track::fraction` never needed this -- the arithmetic is the same whatever /// the numbers mean -- which is exactly how the ruler came to assume minutes /// and print `00:00` over a month. A renderer drawing an axis has to write a /// label, and it cannot derive the unit from the numbers. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] #[non_exhaustive] pub enum Unit { /// Minutes from the start of a day. A day view. #[default] Minutes, /// Whole days. A month strip, a sprint, a stretch of leave. /// /// A day-granularity axis is a *strip*, not a calendar: one line with /// spans laid along it. What it deliberately does not do is wrap into /// weeks, which is the shape that makes weekday periodicity visible and /// the one job of a month grid that a strip cannot take over. See the /// crate header. Days, } /// A window on an axis, in whatever [`Unit`] its [`Track`] counts. /// /// The axis a [`Track`] draws. Offsets rather than instants, because a /// description carrying a `DateTime` would carry a timezone with it and the /// vocabulary has no business holding one. The app knows which day or month /// this is; the description says how far along it a thing sits. /// /// `to` is exclusive and may exceed the natural period, which is how a span /// running past the end is said without a second date: under /// [`Unit::Minutes`], `Span::new(1320, 1560)` is 22:00 to 02:00. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Span { from: u16, to: u16, } impl Span { /// Midnight to midnight, the ordinary day. pub const DAY: Self = Self { from: 0, to: 1440 }; /// A span, clamped to a sane one. /// /// An empty or backwards span is a caller bug that should not cost a /// renderer a division by zero, so `to` is forced at least one minute past /// `from` rather than returning an error nobody can act on. Same reasoning /// as [`Share::percent`], which clamps rather than refuses. #[must_use] pub const fn new(from: u16, to: u16) -> Self { Self { from, to: if to > from { to } else { from + 1 }, } } /// The first minute on the axis. #[must_use] pub const fn from(self) -> u16 { self.from } /// One past the last minute on the axis. #[must_use] pub const fn to(self) -> u16 { self.to } /// How much the axis covers, in its track's unit. Never zero. #[must_use] pub const fn length(self) -> u16 { self.to - self.from } /// Whether an offset falls on this axis. #[must_use] pub const fn holds(self, minute: u16) -> bool { minute >= self.from && minute < self.to } } impl Default for Span { fn default() -> Self { Self::DAY } } /// Where a thing sits on a [`Track`], and for how long. /// /// The one fact a list cannot carry and the whole reason this primitive exists. /// A list says what order things come in; a track says a thing starts 135 /// minutes along and lasts 45, which is a different claim and not derivable /// from the first. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Placement { at: u16, length: u16, } impl Placement { /// A placement, clamped to a drawable one. /// /// Zero length becomes one for the same reason [`Span::new`] clamps: a /// zero-height thing is invisible rather than expressive, and every /// renderer would need its own guard. #[must_use] pub const fn new(at: u16, length: u16) -> Self { Self { at, length: if length == 0 { 1 } else { length }, } } /// Offset from the axis origin, matching [`Span`]'s. #[must_use] pub const fn at(self) -> u16 { self.at } /// How long it lasts, in its track's unit. Never zero. #[must_use] pub const fn length(self) -> u16 { self.length } /// One past its last minute. #[must_use] pub const fn end(self) -> u16 { self.at + self.length } /// Whether two placements cover any of the same time. /// /// Geometry, and deliberately not a described field. Whether an overlap is /// a *conflict* is the app's judgment -- a meeting inside a block of free /// time overlaps and is fine -- and that judgment travels the way every /// other judgment does, as a [`Tone`] on the thing itself. What a renderer /// needs in order to lay two things side by side instead of on top of each /// other is this, and it can compute it. /// /// The alternative was a `conflicts: bool` on each entry, which is state /// that can disagree with the times beside it. Two sources for one fact is /// how a screen starts rendering a conflict badge on a thing that no longer /// conflicts. #[must_use] pub const fn overlaps(self, other: Self) -> bool { self.at < other.end() && other.at < self.end() } } /// A time axis: things placed by when they happen, rather than flowed. /// /// # Why this is a primitive /// /// The argument against naming a timeline is that a description expressive /// enough to draw one is a component library wearing a description's name. It /// does not hold here, and being precise about why matters, because the /// reasoning applies to real cases. /// /// What a timeline needs that a [`List`](Region::Pane) does not is **one** /// thing: placement. Where a thing sits is a fact about the thing, the way a /// row's primary text is, and it is not derivable from order. Everything else a /// day view draws -- the labels, the gridlines, the item bodies, the tones -- /// is furniture this vocabulary already names. Measured against goingson's /// `day-planning-render.js`, the only members it needed and could not get were /// `at` and `minutes`. /// /// So the timeline was never a component library's worth of vocabulary. It was /// two integers, and the refusal was priced as though it were the whole widget. /// The test that matters is not "does this shape look complicated" but "how /// many members does it actually add, and are they facts or presentation". /// Slot heights, gridline colour, how overlaps stack and which hour scrolls /// into view on open are all presentation and all stay the renderer's, which is /// why they are absent here. /// /// # What it does not carry /// /// No pixel measure, no scroll offset, no drag affordance. A renderer draws the /// span at whatever density its host uses; `makeover-geometry` owns that the /// way it owns everything else measured in pixels. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Track { /// The window the axis covers. pub span: Span, /// The granularity a thing can be placed on, in minutes. /// /// goingson's day view is 15, giving 96 slots across a day. A renderer uses /// it to decide where gridlines fall and what a drop lands on; it does not /// constrain [`Placement`], because data arriving from a calendar does not /// respect anyone's grid. pub slot: u16, /// How often the axis labels itself, in its own unit. /// /// 60 gives an hourly ruler over a 15-minute grid, which is the common /// shape and the reason this is separate from `slot`. Zero means an /// unlabelled axis. pub tick: u16, /// What `span`, `slot`, `tick` and every [`Placement`] on it count. /// /// The one field here a renderer cannot derive, and the reason it exists: /// [`fraction`](Self::fraction) is unit-agnostic, so a day-granularity /// track produced correct geometry under an hours-and-minutes ruler until /// this was added. Geometry never needed it; a label always did. pub unit: Unit, } impl Track { /// An ordinary day: midnight to midnight, quarter-hour slots, hourly ticks. pub const DAY: Self = Self { span: Span::DAY, slot: 15, tick: 60, unit: Unit::Minutes, }; /// A track over `span`, with the day's usual granularity. #[must_use] pub const fn over(span: Span) -> Self { Self { span, slot: 15, tick: 60, unit: Unit::Minutes, } } /// A strip of whole days: one slot a day, a label a week. /// /// The shape a stretch of leave or a sprint is drawn on. Not a calendar -- /// it does not wrap into weeks, and the crate header says why that /// distinction is the whole of what a month grid still has over this. #[must_use] pub const fn days(span: Span) -> Self { Self { span, slot: 1, tick: 7, unit: Unit::Days, } } /// How many slots the axis holds. /// /// Rounded up, so a span that does not divide evenly by `slot` still has a /// slot covering its tail rather than dropping it. Never zero: `slot` of 0 /// reads as one slot spanning the whole axis rather than a division by /// zero, since a renderer asking this question has already committed to /// drawing something. #[must_use] pub const fn slots(self) -> u16 { if self.slot == 0 { 1 } else { self.span.length().div_ceil(self.slot) } } /// Where a placement sits on the axis, as a fraction from 0.0 to 1.0. /// /// The one calculation every renderer would otherwise write itself, and the /// place the three would drift apart. Clamped, so a placement outside the /// span draws at the edge rather than off it -- an event running past /// midnight is a real thing and truncating it is better than either /// panicking or drawing it somewhere impossible. #[must_use] pub fn fraction(self, minute: u16) -> f32 { let span = f32::from(self.span.length()); let offset = f32::from(minute.saturating_sub(self.span.from())); (offset / span).clamp(0.0, 1.0) } } impl Default for Track { fn default() -> Self { Self::DAY } }