makeover_layout/track.rs
1// Names this module's prose links to, resolved for rustdoc.
2#[allow(unused_imports)]
3use crate::{Region, Share, Tone};
4
5/// What a [`Track`]'s integers count.
6///
7/// `Track::fraction` never needed this (the arithmetic is the same whatever
8/// the numbers mean) which is exactly how the ruler came to assume minutes
9/// and print `00:00` over a month. A renderer drawing an axis has to write a
10/// label, and it cannot derive the unit from the numbers.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
12#[non_exhaustive]
13pub enum Unit {
14 /// Minutes from the start of a day. A day view.
15 #[default]
16 Minutes,
17 /// Whole days. A month strip, a sprint, a stretch of leave.
18 ///
19 /// A day-granularity axis is a *strip*, not a calendar: one line with
20 /// spans laid along it. What it deliberately does not do is wrap into
21 /// weeks, which is the shape that makes weekday periodicity visible and
22 /// the one job of a month grid that a strip cannot take over. See the
23 /// crate header.
24 Days,
25}
26
27/// A window on an axis, in whatever [`Unit`] its [`Track`] counts.
28///
29/// The axis a [`Track`] draws. Offsets rather than instants, because a
30/// description carrying a `DateTime` would carry a timezone with it and the
31/// vocabulary has no business holding one. The app knows which day or month
32/// this is; the description says how far along it a thing sits.
33///
34/// `to` is exclusive and may exceed the natural period, which is how a span
35/// running past the end is said without a second date: under
36/// [`Unit::Minutes`], `Span::new(1320, 1560)` is 22:00 to 02:00.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38#[non_exhaustive]
39pub struct Span {
40 from: u16,
41 to: u16,
42}
43
44impl Span {
45 /// Midnight to midnight, the ordinary day.
46 pub const DAY: Self = Self { from: 0, to: 1440 };
47
48 /// A span, clamped to a sane one.
49 ///
50 /// An empty or backwards span is a caller bug that should not cost a
51 /// renderer a division by zero, so `to` is forced at least one minute past
52 /// `from` rather than returning an error nobody can act on. Same reasoning
53 /// as [`Share::percent`], which clamps rather than refuses. `from` is held
54 /// one short of `u16::MAX` so there is always a minute after it.
55 #[must_use]
56 pub const fn new(from: u16, to: u16) -> Self {
57 let from = if from == u16::MAX { u16::MAX - 1 } else { from };
58 Self {
59 from,
60 to: if to > from { to } else { from + 1 },
61 }
62 }
63
64 /// The first minute on the axis.
65 #[must_use]
66 pub const fn from(self) -> u16 {
67 self.from
68 }
69
70 /// One past the last minute on the axis.
71 #[must_use]
72 pub const fn to(self) -> u16 {
73 self.to
74 }
75
76 /// How much the axis covers, in its track's unit. Never zero.
77 #[must_use]
78 pub const fn length(self) -> u16 {
79 self.to - self.from
80 }
81
82 /// Whether an offset falls on this axis.
83 #[must_use]
84 pub const fn holds(self, minute: u16) -> bool {
85 minute >= self.from && minute < self.to
86 }
87}
88
89impl Default for Span {
90 fn default() -> Self {
91 Self::DAY
92 }
93}
94
95/// Where a thing sits on a [`Track`], and for how long.
96///
97/// The one fact a list cannot carry and the whole reason this primitive exists.
98/// A list says what order things come in; a track says a thing starts 135
99/// minutes along and lasts 45, which is a different claim and not derivable
100/// from the first.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102#[non_exhaustive]
103pub struct Placement {
104 at: u16,
105 length: u16,
106}
107
108impl Placement {
109 /// A placement, clamped to a drawable one.
110 ///
111 /// Zero length becomes one for the same reason [`Span::new`] clamps: a
112 /// zero-height thing is invisible rather than expressive, and every
113 /// renderer would need its own guard.
114 #[must_use]
115 pub const fn new(at: u16, length: u16) -> Self {
116 // Held inside the axis, so `end` cannot pass `u16::MAX` and two
117 // placements at the far end still compare as overlapping.
118 let at = if at == u16::MAX { u16::MAX - 1 } else { at };
119 let room = u16::MAX - at;
120 let length = if length == 0 {
121 1
122 } else if length > room {
123 room
124 } else {
125 length
126 };
127 Self { at, length }
128 }
129
130 /// Offset from the axis origin, matching [`Span`]'s.
131 #[must_use]
132 pub const fn at(self) -> u16 {
133 self.at
134 }
135
136 /// How long it lasts, in its track's unit. Never zero.
137 #[must_use]
138 pub const fn length(self) -> u16 {
139 self.length
140 }
141
142 /// One past its last minute.
143 #[must_use]
144 pub const fn end(self) -> u16 {
145 self.at + self.length
146 }
147
148 /// Whether two placements cover any of the same time.
149 ///
150 /// Geometry, and deliberately not a described field. Whether an overlap is
151 /// a *conflict* is the app's judgment (a meeting inside a block of free
152 /// time overlaps and is fine) and that judgment travels the way every
153 /// other judgment does, as a [`Tone`] on the thing itself. What a renderer
154 /// needs in order to lay two things side by side instead of on top of each
155 /// other is this, and it can compute it.
156 ///
157 /// The alternative was a `conflicts: bool` on each entry, which is state
158 /// that can disagree with the times beside it. Two sources for one fact is
159 /// how a screen starts rendering a conflict badge on a thing that no longer
160 /// conflicts.
161 #[must_use]
162 pub const fn overlaps(self, other: Self) -> bool {
163 self.at < other.end() && other.at < self.end()
164 }
165}
166
167/// A time axis: things placed by when they happen, rather than flowed.
168///
169/// # Why this is a primitive
170///
171/// The argument against naming a timeline is that a description expressive
172/// enough to draw one is a component library wearing a description's name. It
173/// does not hold here, and being precise about why matters, because the
174/// reasoning applies to real cases.
175///
176/// What a timeline needs that a [`List`](Region::Pane) does not is **one**
177/// thing: placement. Where a thing sits is a fact about the thing, the way a
178/// row's primary text is, and it is not derivable from order. Everything else a
179/// day view draws, the labels, the gridlines, the item bodies, the tones --
180/// is furniture this vocabulary already names. Measured against goingson's
181/// `day-planning-render.js`, the only members it needed and could not get were
182/// `at` and `minutes`.
183///
184/// So the timeline was never a component library's worth of vocabulary. It was
185/// two integers, and the refusal was priced as though it were the whole widget.
186/// The test that matters is not "does this shape look complicated" but "how
187/// many members does it actually add, and are they facts or presentation".
188/// Slot heights, gridline colour, how overlaps stack and which hour scrolls
189/// into view on open are all presentation and all stay the renderer's, which is
190/// why they are absent here.
191///
192/// # What it does not carry
193///
194/// No pixel measure, no scroll offset, no drag affordance. A renderer draws the
195/// span at whatever density its host uses; `makeover-geometry` owns that the
196/// way it owns everything else measured in pixels.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
198#[non_exhaustive]
199pub struct Track {
200 /// The window the axis covers.
201 pub span: Span,
202 /// The granularity a thing can be placed on, in minutes.
203 ///
204 /// goingson's day view is 15, giving 96 slots across a day. A renderer uses
205 /// it to decide where gridlines fall and what a drop lands on; it does not
206 /// constrain [`Placement`], because data arriving from a calendar does not
207 /// respect anyone's grid.
208 pub slot: u16,
209 /// How often the axis labels itself, in its own unit.
210 ///
211 /// 60 gives an hourly ruler over a 15-minute grid, which is the common
212 /// shape and the reason this is separate from `slot`. Zero means an
213 /// unlabelled axis.
214 pub tick: u16,
215 /// What `span`, `slot`, `tick` and every [`Placement`] on it count.
216 ///
217 /// The one field here a renderer cannot derive, and the reason it exists:
218 /// [`fraction`](Self::fraction) is unit-agnostic, so a day-granularity
219 /// track produced correct geometry under an hours-and-minutes ruler until
220 /// this was added. Geometry never needed it; a label always did.
221 pub unit: Unit,
222}
223
224impl Track {
225 /// An ordinary day: midnight to midnight, quarter-hour slots, hourly ticks.
226 pub const DAY: Self = Self::over(Span::DAY);
227
228 /// A track over `span`, with the day's usual granularity.
229 #[must_use]
230 pub const fn over(span: Span) -> Self {
231 Self {
232 span,
233 slot: 15,
234 tick: 60,
235 unit: Unit::Minutes,
236 }
237 }
238
239 /// A strip of whole days: one slot a day, a label a week.
240 ///
241 /// The shape a stretch of leave or a sprint is drawn on. Not a calendar --
242 /// it does not wrap into weeks, and the crate header says why that
243 /// distinction is the whole of what a month grid still has over this.
244 #[must_use]
245 pub const fn days(span: Span) -> Self {
246 Self {
247 span,
248 slot: 1,
249 tick: 7,
250 unit: Unit::Days,
251 }
252 }
253
254 /// The same track, placing on this granularity, in its own unit.
255 #[must_use]
256 pub const fn slot(mut self, slot: u16) -> Self {
257 self.slot = slot;
258 self
259 }
260
261 /// The same track, labelling itself this often. Zero is unlabelled.
262 #[must_use]
263 pub const fn tick(mut self, tick: u16) -> Self {
264 self.tick = tick;
265 self
266 }
267
268 /// The same track, counting in this unit.
269 #[must_use]
270 pub const fn unit(mut self, unit: Unit) -> Self {
271 self.unit = unit;
272 self
273 }
274
275 /// How many slots the axis holds.
276 ///
277 /// Rounded up, so a span that does not divide evenly by `slot` still has a
278 /// slot covering its tail rather than dropping it. Never zero: `slot` of 0
279 /// reads as one slot spanning the whole axis rather than a division by
280 /// zero, since a renderer asking this question has already committed to
281 /// drawing something.
282 #[must_use]
283 pub const fn slots(self) -> u16 {
284 if self.slot == 0 {
285 1
286 } else {
287 self.span.length().div_ceil(self.slot)
288 }
289 }
290
291 /// Where a placement sits on the axis, as a fraction from 0.0 to 1.0.
292 ///
293 /// The one calculation every renderer would otherwise write itself, and the
294 /// place the three would drift apart. Clamped, so a placement outside the
295 /// span draws at the edge rather than off it, an event running past
296 /// midnight is a real thing and truncating it is better than either
297 /// panicking or drawing it somewhere impossible.
298 #[must_use]
299 pub fn fraction(self, minute: u16) -> f32 {
300 let span = f32::from(self.span.length());
301 let offset = f32::from(minute.saturating_sub(self.span.from()));
302 (offset / span).clamp(0.0, 1.0)
303 }
304}
305
306impl Default for Track {
307 fn default() -> Self {
308 Self::DAY
309 }
310}