Skip to main content

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}