makeover-layout 0.46.0

The renderer-agnostic half of the make-family design system: what a thing IS, named as intents and relationships and never as values. Colour defers to makeover, spacing to makeover-geometry; what is left is composition.
Documentation
// 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
    }
}