qframe/widgets/timeline/mod.rs
1//! Timelines: a day on a time axis, with blocks for what filled it and gaps for what did not.
2//!
3//! The model an application builds is here, together with the input handling; `layout` works
4//! out where the day, its blocks and their lanes sit and `paint` draws them.
5
6mod layout;
7mod paint;
8mod readout;
9#[cfg(test)]
10mod tests;
11
12use crate::date::TimeOfDay;
13use crate::event::{Event, MouseButton, MouseKind};
14use crate::geometry::{Rect, Size};
15use crate::keymap::Key;
16use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
17
18use super::IndexMessage;
19use super::axis::DAY;
20
21/// The visible stretches zooming steps through, in seconds: a day, half a day, six hours, three
22/// hours and one hour. An hour is the closest a day strip zooms in: below it a block of a few
23/// minutes already spans several cells.
24const ZOOM_SPANS: [u32; 5] = [DAY, 43_200, 21_600, 10_800, 3_600];
25
26/// Builds a message from a new visible range.
27type ZoomMessage<Msg> = Box<dyn Fn(TimeOfDay, TimeOfDay) -> Msg>;
28
29/// One block of a [`Timeline`]: a stretch of the day with a name, such as work from 09:00 to
30/// 11:10.
31///
32/// A block whose end is not after its start runs past midnight into the next day; one whose end
33/// is its start is a moment, still drawn one cell wide.
34///
35/// A block that does not really stop where the strip shows it stopping — a session that runs on
36/// past midnight, one carried over from the day before, a counter still running — marks that
37/// edge with [`open_end`](Self::open_end) or [`open_start`](Self::open_start), and a block that
38/// only echoes time recorded elsewhere, such as the next day's part of that session, is
39/// [`faint`](Self::faint).
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct TimeBlock {
42 label: String,
43 start: TimeOfDay,
44 end: TimeOfDay,
45 tone: Option<usize>,
46 open_start: bool,
47 open_end: bool,
48 faint: bool,
49}
50
51impl TimeBlock {
52 /// A block called `label` from `start` to `end`.
53 #[must_use]
54 pub fn new(label: impl Into<String>, start: TimeOfDay, end: TimeOfDay) -> Self {
55 Self { label: label.into(), start, end, tone: None, open_start: false, open_end: false, faint: false }
56 }
57
58 /// Takes the theme's `index`-th series tone
59 /// ([`Theme::series_color`](crate::theme::Theme::series_color)) instead of the accent, for a
60 /// block of one category among several. Give the category the same index in the
61 /// [`Legend`](super::Legend) beside the timeline, through [`Legend::tones`](super::Legend::tones).
62 #[must_use]
63 pub fn tone(mut self, index: usize) -> Self {
64 self.tone = Some(index);
65 self
66 }
67
68 /// Leaves the end open: the block goes on after the time it is drawn to, because it runs past
69 /// the end of the day or because it is still running. Its last cells fade towards the track
70 /// instead of stopping square, and the readout says which: "continues next day" for a block
71 /// that reaches the day's end, "running" for one that stops before it.
72 ///
73 /// Only the block's own end is drawn open. Where a zoomed [`range`](Timeline::range) cuts the
74 /// block the edge stays square, as the range is where the view stops, not where the block
75 /// does. A block too short for a fade keeps its tone, and the readout still says it.
76 #[must_use]
77 pub fn open_end(mut self) -> Self {
78 self.open_end = true;
79 self
80 }
81
82 /// Leaves the start open: the block began before the time it is drawn from, such as a session
83 /// carried over from the previous day. Its first cells fade in from the track, and the readout
84 /// says "from the previous day" for a block that starts with the day, "from earlier" for one
85 /// that starts later. Drawn like [`open_end`](Self::open_end), at the other edge.
86 #[must_use]
87 pub fn open_start(mut self) -> Self {
88 self.open_start = true;
89 self
90 }
91
92 /// Draws the block in a quieter tone, halfway between its own and the empty track, for time
93 /// that belongs to something shown in full elsewhere — the next day's part of a session that
94 /// started the night before. It still answers hover and selection, and it keeps its category's
95 /// tone family, so it reads as the same thing, fainter.
96 #[must_use]
97 pub fn faint(mut self) -> Self {
98 self.faint = true;
99 self
100 }
101}
102
103/// A day as a strip: blocks of colour for what filled it, the empty track for the gaps between
104/// them — breaks, sleep, time not recorded.
105///
106/// The day is 24 hours from [`day_starts_at`](Self::day_starts_at), midnight by default. Blocks
107/// are placed on it by time: a block covers the cells its stretch falls in, and a block shorter
108/// than a cell still takes one cell, so a two-minute task is never lost on a whole-day strip.
109/// Blocks without a [`tone`](TimeBlock::tone) take the accent; two blocks of the same tone that
110/// touch are told apart by a quieter first cell on the later one, a seam of tone rather than a
111/// line. A block wide enough for its name with a cell of air on each side writes the name inside
112/// itself.
113///
114/// **Overlapping blocks** stand in lanes: each block takes the first lane, from the top, in which
115/// nothing overlaps it, so a strip is one row tall for a day of consecutive blocks and grows a
116/// row for each block that runs at the same time as another. An area shorter than the lanes puts
117/// the lanes that do not fit into its last row, where the later block is drawn over the earlier
118/// and the selected block is always drawn on top.
119///
120/// **Midnight.** A day that starts at 18:00 runs to 18:00 the next day, so a block from 23:00 to
121/// 01:30 is one block in it. In a day that starts at midnight the same block is cut at the end
122/// of the day; its part after midnight belongs to the next day's strip, where the caller gives
123/// it from 00:00. A [`range`](Self::range) is placed in the same day, so a night from 22:00 to
124/// 06:00 is a range of a day that starts before 22:00.
125///
126/// **Zoom** is the visible range, which the caller owns: [`range`](Self::range) shows a stretch
127/// of the day across the whole width, and with [`on_zoom`](Self::on_zoom) the timeline asks for
128/// a new one — `+` and `-` step through a day, 12, 6 and 3 hours and one hour around the selected
129/// block, `0` goes back to the whole day, and the mouse wheel zooms around the pointer once the
130/// timeline holds the focus (a click gives it), so scrolling a page past a timeline never gets
131/// caught in it. Selecting a block outside a zoomed range moves the range to it.
132///
133/// A timeline is a picture until it is given [`on_select`](Self::on_select). Then the block
134/// under the pointer and the selected block step towards the text colour, ←/→ (or h/l) walk the
135/// blocks in time order, Home and End go to the first and the last, and a click selects the
136/// block under it. [`readout`](Self::readout) adds a row that writes the block being read — the
137/// one under the pointer, else the selected one — as its name, its times and its length, so the
138/// pointer and the keyboard read the same words. Nothing moves or resizes when a block is
139/// hovered or selected: a timeline is a narrow strip, not a list, so it never slides.
140///
141/// **Open edges and faint blocks.** A block marked [`open_end`](TimeBlock::open_end) or
142/// [`open_start`](TimeBlock::open_start) fades towards the track over its last or first two
143/// cells, a tone transition rather than a glyph, and the readout writes what the open edge means.
144/// A [`faint`](TimeBlock::faint) block stands halfway between its tone and the track. On a
145/// terminal with few colours every fading cell and every faint tone is kept apart from the track,
146/// so a block never looks shorter than it is.
147///
148/// [`axis`](Self::axis) adds a row of hours under the strip, an [`Axis`](super::Axis) placed with
149/// the same arithmetic as the blocks. An area too short for every row gives up the axis first,
150/// then the readout, then lanes. The strip is made of colour, so it reads the same in every glyph
151/// mode; on a terminal with few colours a tone that would merge with the track or with its own
152/// hovered step is pushed further until the two differ.
153///
154/// Style keys: `timeline` (`track` for the empty day, `fill` for a block without a tone, `hover`
155/// and `selected` for the tones a block steps towards), `timeline:focus` (`selected` while the
156/// keyboard is on the timeline), `timeline-readout` (`fg` for the name, `detail` for the times
157/// and the length), and `axis` for the hours.
158pub struct Timeline<Msg> {
159 blocks: Vec<TimeBlock>,
160 day_start: TimeOfDay,
161 range: Option<(TimeOfDay, TimeOfDay)>,
162 axis: bool,
163 readout: bool,
164 selected: Option<usize>,
165 disabled: bool,
166 on_select: Option<IndexMessage<Msg>>,
167 on_zoom: Option<ZoomMessage<Msg>>,
168}
169
170impl<Msg: 'static> Timeline<Msg> {
171 /// A whole-day timeline of `blocks`, a day that starts at midnight.
172 #[must_use]
173 pub fn new(blocks: impl IntoIterator<Item = TimeBlock>) -> Self {
174 Self {
175 blocks: blocks.into_iter().collect(),
176 day_start: TimeOfDay::default(),
177 range: None,
178 axis: false,
179 readout: false,
180 selected: None,
181 disabled: false,
182 on_select: None,
183 on_zoom: None,
184 }
185 }
186
187 /// Starts the day at `time` instead of midnight, e.g. 18:00 for a night shift, so blocks
188 /// across midnight stay whole.
189 #[must_use]
190 pub fn day_starts_at(mut self, time: TimeOfDay) -> Self {
191 self.day_start = time;
192 self
193 }
194
195 /// Shows only the stretch from `from` to `to` across the whole width: into the next day when
196 /// `to` is not after `from`, the whole day when the two are the same. The range is kept
197 /// inside the day, so a range that runs past the day's end stops there.
198 #[must_use]
199 pub fn range(mut self, from: TimeOfDay, to: TimeOfDay) -> Self {
200 self.range = Some((from, to));
201 self
202 }
203
204 /// Adds a row of hours under the strip.
205 #[must_use]
206 pub fn axis(mut self) -> Self {
207 self.axis = true;
208 self
209 }
210
211 /// Adds a row that writes the block being read: its name, its times and its length.
212 #[must_use]
213 pub fn readout(mut self) -> Self {
214 self.readout = true;
215 self
216 }
217
218 /// The selected block, as an index into the blocks given.
219 #[must_use]
220 pub fn selected(mut self, index: Option<usize>) -> Self {
221 self.selected = index;
222 self
223 }
224
225 /// Greys the timeline out: it cannot be focused, hovered, selected or zoomed.
226 #[must_use]
227 pub fn disabled(mut self, disabled: bool) -> Self {
228 self.disabled = disabled;
229 self
230 }
231
232 /// Message for moving the selection to a block, carrying its index into the blocks given;
233 /// turns the pointer and keyboard handling on.
234 #[must_use]
235 pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
236 self.on_select = Some(Box::new(message));
237 self
238 }
239
240 /// Message asking for a new visible range, `from` and `to` in the sense of
241 /// [`range`](Self::range); turns zooming on.
242 #[must_use]
243 pub fn on_zoom(mut self, message: impl Fn(TimeOfDay, TimeOfDay) -> Msg + 'static) -> Self {
244 self.on_zoom = Some(Box::new(message));
245 self
246 }
247
248 /// Whether blocks answer the pointer and the keyboard.
249 fn selectable(&self) -> bool {
250 self.on_select.is_some() && !self.disabled && !self.blocks.is_empty()
251 }
252
253 /// Whether the range answers `+`, `-`, `0` and the wheel.
254 fn zoomable(&self) -> bool {
255 self.on_zoom.is_some() && !self.disabled
256 }
257
258 /// Moves the selection to `index` and, in a zoomed range the block is not wholly inside,
259 /// asks for the range to move to it.
260 fn select(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
261 if let Some(message) = &self.on_select
262 && self.selected != Some(index)
263 {
264 cx.emit(message(index));
265 }
266 let (from, span) = self.visible();
267 let Some(block) = self.blocks.get(index) else { return };
268 let (start, end) = self.extent(block);
269 if span >= DAY || (start >= from && end <= from + span) {
270 return;
271 }
272 let middle = start + (end - start) / 2;
273 let moved = middle.saturating_sub(span / 2).min(DAY - span);
274 self.ask_range(cx, moved - moved % 60, span);
275 }
276
277 /// Steps the visible range one zoom step in or out around `anchor` (seconds into the day),
278 /// keeping the anchor where it is on screen. At either end of the steps nothing is asked.
279 fn zoom(&self, cx: &mut EventCx<'_, Msg>, inward: bool, anchor: u32) {
280 let (from, span) = self.visible();
281 let next = if inward {
282 ZOOM_SPANS.into_iter().find(|s| *s < span)
283 } else {
284 ZOOM_SPANS.into_iter().rev().find(|s| *s > span)
285 };
286 let Some(next) = next else { return };
287 let anchor = anchor.clamp(from, from + span);
288 let before = u64::from(anchor - from) * u64::from(next) / u64::from(span);
289 let start = anchor.saturating_sub(u32::try_from(before).unwrap_or(0)).min(DAY - next);
290 self.ask_range(cx, start - start % 60, next);
291 }
292
293 /// Asks for the range of `span` seconds from `from` seconds into the day.
294 fn ask_range(&self, cx: &mut EventCx<'_, Msg>, from: u32, span: u32) {
295 if let Some(message) = &self.on_zoom
296 && (from, span) != self.visible()
297 {
298 cx.emit(message(self.clock(from), self.clock(from + span)));
299 }
300 }
301
302 /// The anchor the keyboard zooms around: the middle of the selected block when it is in
303 /// view, else the middle of the range.
304 fn keyboard_anchor(&self) -> u32 {
305 let (from, span) = self.visible();
306 self.selected
307 .and_then(|index| self.blocks.get(index))
308 .map(|block| self.extent(block))
309 .filter(|(start, end)| *end > from && *start < from + span)
310 .map_or(from + span / 2, |(start, end)| start + (end - start) / 2)
311 }
312
313 /// The block a key moves the selection to, in time order.
314 fn key_target(&self, key: &crate::event::KeyEvent) -> Option<usize> {
315 let order = self.order();
316 let last = order.len().checked_sub(1)?;
317 let at = self.selected.and_then(|selected| order.iter().position(|index| *index == selected));
318 let position = if key.is_plain(Key::Left) || key.is_plain(Key::Char('h')) {
319 at.map_or(last, |at| at.saturating_sub(1))
320 } else if key.is_plain(Key::Right) || key.is_plain(Key::Char('l')) {
321 at.map_or(0, |at| (at + 1).min(last))
322 } else if key.is_plain(Key::Home) {
323 0
324 } else if key.is_plain(Key::End) {
325 last
326 } else {
327 return None;
328 };
329 order.get(position).copied()
330 }
331
332 /// Handles `+`, `-` and `0`; says whether the key was one of them.
333 fn zoom_key(&self, cx: &mut EventCx<'_, Msg>, key: &crate::event::KeyEvent) -> bool {
334 let plain = |c: char| key.is_plain(Key::Char(c));
335 if plain('+') || plain('=') {
336 self.zoom(cx, true, self.keyboard_anchor());
337 } else if plain('-') {
338 self.zoom(cx, false, self.keyboard_anchor());
339 } else if plain('0') {
340 self.ask_range(cx, 0, DAY);
341 } else {
342 return false;
343 }
344 true
345 }
346}
347
348impl<Msg: 'static> Widget<Msg> for Timeline<Msg> {
349 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
350 let rows = self.lanes().saturating_add(u16::from(self.axis)).saturating_add(u16::from(self.readout));
351 Size::new(available.width, rows).min(available)
352 }
353
354 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
355 if area.is_empty() {
356 return;
357 }
358 if self.selectable() || self.zoomable() {
359 cx.register_hit(area);
360 }
361 self.paint_all(cx, area);
362 }
363
364 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
365 if !self.focusable() {
366 return false;
367 }
368 let area = cx.area();
369 match event {
370 Event::Key(key) => {
371 if self.zoomable() && self.zoom_key(cx, key) {
372 return true;
373 }
374 if !self.selectable() {
375 return false;
376 }
377 let Some(target) = self.key_target(key) else { return false };
378 self.select(cx, target);
379 true
380 }
381 Event::Mouse(mouse) => match mouse.kind {
382 MouseKind::Down(MouseButton::Left) if self.selectable() => {
383 let Some(index) = self.block_at(area, mouse.x, mouse.y) else { return false };
384 self.select(cx, index);
385 true
386 }
387 MouseKind::ScrollUp | MouseKind::ScrollDown if self.zoomable() && cx.is_focused() => {
388 let anchor = self.time_at(area, mouse.x);
389 self.zoom(cx, mouse.kind == MouseKind::ScrollUp, anchor);
390 true
391 }
392 _ => false,
393 },
394 _ => false,
395 }
396 }
397
398 fn focusable(&self) -> bool {
399 self.selectable() || self.zoomable()
400 }
401}