Skip to main content

qframe/widgets/
axis.rs

1//! Axes: a row of labels along the edge of a chart — hours of a day, weekdays, months, or names
2//! of your own — thinned so they never collide.
3
4use crate::date::{TimeOfDay, Weekday};
5use crate::geometry::{Rect, Size};
6use crate::style::CellStyle;
7use crate::text;
8use crate::widget::{MeasureCx, PaintCx, Widget};
9
10/// Seconds in a day.
11pub(crate) const DAY: u32 = 86_400;
12
13/// Minutes between two hour labels, from the finest to the coarsest. Every step divides a day,
14/// so the labels fall on the same clock times whatever the range starts at.
15const HOUR_STEPS: [u32; 9] = [15, 30, 60, 120, 180, 240, 360, 720, 1_440];
16
17/// The shortest step that writes whole hours only, where the short form of a label can stand.
18const WHOLE_HOURS: u32 = 60;
19
20/// Seconds from `from` forward to `to` on a clock: into the next day when `to` is not after
21/// `from`, and a whole day when the two are the same moment.
22pub(crate) fn span_between(from: TimeOfDay, to: TimeOfDay) -> u32 {
23    let span = (to.seconds_since_midnight() + DAY - from.seconds_since_midnight()) % DAY;
24    if span == 0 { DAY } else { span }
25}
26
27/// The cell, counted from the left edge of `width` cells, that `offset` seconds into a range of
28/// `span` seconds falls in. A timeline and its axis both place time with this, so a label sits
29/// over the cell its time is drawn in.
30pub(crate) fn time_cell(offset: u32, span: u32, width: u16) -> i32 {
31    let cell = u64::from(offset) * u64::from(width) / u64::from(span.max(1));
32    i32::try_from(cell).unwrap_or(i32::MAX)
33}
34
35/// What the labels of an axis stand for.
36#[derive(Debug, Clone, PartialEq, Eq)]
37enum Kind {
38    /// A stretch of a day: `span` seconds from `from` seconds after midnight.
39    Hours { from: u32, span: u32 },
40    /// Consecutive days starting at `first`.
41    Weekdays { first: Weekday, count: usize },
42    /// Consecutive months starting at `first` (1 January to 12 December).
43    Months { first: u8, count: usize },
44    /// Names the caller gives, one per slot.
45    Labels(Vec<String>),
46}
47
48/// A row of labels along the edge of a chart: hours of a day, weekdays, months, or names of your
49/// own.
50///
51/// Hours are placed by time: a label starts in the cell its clock time falls in, the way a
52/// [`Timeline`](super::Timeline) places a block, so an axis under a strip of the same range reads
53/// straight down. Weekdays, months and names of your own are placed in slots: the width is shared
54/// out into one slot per label, with [`gap`](Self::gap) empty cells between slots, and each label
55/// is centred in its slot. A vertical [`BarChart`](super::BarChart) shares its width out the same
56/// way, so an axis with the chart's gap stands under its bars.
57///
58/// Names come from the active language — `quvyta.date.weekday-long-*`, `weekday-*`, `month-*`,
59/// `month-short-*`, and `quvyta.time.clock` and `hour` for times — never from the source.
60///
61/// Labels never collide and are never cut. Weekdays and months write every name in full while
62/// all of them fit, then every name in its short form, then the short form of every second,
63/// third, … name. Hours take the finest step whose labels fit — a quarter hour, half an hour, an
64/// hour, then 2, 3, 4, 6, 12 hours and a day, always on round clock times — in the long form
65/// (`09:30`), and fall back to the short form (`09`) only when the long one leaves fewer than two
66/// labels, which gives a reader no scale. At least one cell stays empty between two labels, and a
67/// label that would run past the right edge is left out rather than cut. An area too narrow for a
68/// single label writes nothing.
69///
70/// An axis is text only, so it reads the same in every glyph mode; it takes no focus and sends
71/// no messages.
72///
73/// Style keys: `axis` (`fg`).
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct Axis {
76    kind: Kind,
77    gap: u16,
78    faint: bool,
79}
80
81impl Axis {
82    /// The hours from `from` to `to`: into the next day when `to` is not after `from`, a whole
83    /// day when the two are the same.
84    #[must_use]
85    pub fn hours(from: TimeOfDay, to: TimeOfDay) -> Self {
86        Self::of(Kind::Hours { from: from.seconds_since_midnight(), span: span_between(from, to) })
87    }
88
89    /// `count` weekdays from `first` on, wrapping after Sunday.
90    #[must_use]
91    pub fn weekdays(first: Weekday, count: usize) -> Self {
92        Self::of(Kind::Weekdays { first, count })
93    }
94
95    /// `count` months from `first` on (1 for January to 12 for December), wrapping after
96    /// December. A `first` outside 1 to 12 is taken as the nearest month.
97    #[must_use]
98    pub fn months(first: u8, count: usize) -> Self {
99        Self::of(Kind::Months { first: first.clamp(1, 12), count })
100    }
101
102    /// Names of your own, one slot each, e.g. the weeks of a quarter. A name with no room in the
103    /// active width is left out, never cut.
104    #[must_use]
105    pub fn labels(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
106        Self::of(Kind::Labels(labels.into_iter().map(Into::into).collect()))
107    }
108
109    /// Empty cells between two slots; none by default. Give it the gap of the chart above so the
110    /// labels stand under its bars. Hours are placed by time and have no slots, so they ignore it.
111    #[must_use]
112    pub fn gap(mut self, cells: u16) -> Self {
113        self.gap = cells;
114        self
115    }
116
117    fn of(kind: Kind) -> Self {
118        Self { kind, gap: 0, faint: false }
119    }
120
121    /// Draws the labels in the faint tone, for the axis of a disabled chart.
122    pub(crate) fn faint(mut self, faint: bool) -> Self {
123        self.faint = faint;
124        self
125    }
126
127    /// How many labels the axis could write before thinning.
128    fn count(&self) -> usize {
129        match &self.kind {
130            Kind::Hours { .. } => 1,
131            Kind::Weekdays { count, .. } | Kind::Months { count, .. } => *count,
132            Kind::Labels(labels) => labels.len(),
133        }
134    }
135
136    /// The labels written in `width` cells: where each starts, counted from the left edge, and
137    /// its text. Reads names from the active language.
138    fn place(&self, width: u16) -> Vec<(i32, String)> {
139        match &self.kind {
140            Kind::Hours { from, span } => hour_labels(*from, *span, width),
141            Kind::Weekdays { first, count } => {
142                let forms: Vec<Vec<String>> = (0..*count)
143                    .map(|index| {
144                        let day = (usize::from(first.number()) - 1 + index) % 7 + 1;
145                        vec![
146                            crate::t!(&format!("quvyta.date.weekday-long-{day}")),
147                            crate::t!(&format!("quvyta.date.weekday-{day}")),
148                        ]
149                    })
150                    .collect();
151                self.slot_labels(&forms, width)
152            }
153            Kind::Months { first, count } => {
154                let forms: Vec<Vec<String>> = (0..*count)
155                    .map(|index| {
156                        let month = (usize::from(*first) - 1 + index) % 12 + 1;
157                        vec![
158                            crate::t!(&format!("quvyta.date.month-{month}")),
159                            crate::t!(&format!("quvyta.date.month-short-{month}")),
160                        ]
161                    })
162                    .collect();
163                self.slot_labels(&forms, width)
164            }
165            Kind::Labels(labels) => {
166                let forms: Vec<Vec<String>> = labels.iter().map(|label| vec![label.clone()]).collect();
167                self.slot_labels(&forms, width)
168            }
169        }
170    }
171
172    /// Labels centred in equal slots. Every label in its longest form while they all fit, then
173    /// every label in each shorter form, then the shortest form of every second, third, … label.
174    fn slot_labels(&self, forms: &[Vec<String>], width: u16) -> Vec<(i32, String)> {
175        let count = forms.len();
176        if count == 0 || width == 0 {
177            return Vec::new();
178        }
179        let gaps = u16::try_from(count - 1).unwrap_or(u16::MAX).saturating_mul(self.gap);
180        let slots = u16::try_from(count).unwrap_or(u16::MAX);
181        let slot = (width.saturating_sub(gaps) / slots).max(1);
182        let place = |index: usize, label: &str| {
183            let start = i32::try_from(index).unwrap_or(i32::MAX).saturating_mul(i32::from(slot) + i32::from(self.gap));
184            let label_width = i32::from(text::width(label));
185            let x = start + (i32::from(slot) - label_width) / 2;
186            // A label wider than its slot leans into its neighbours' room but stays inside the
187            // axis; the collision check decides whether that room was free.
188            (x.min(i32::from(width) - label_width).max(0), label.to_owned())
189        };
190        let longest = forms.iter().map(Vec::len).max().unwrap_or(0);
191        for form in 0..longest {
192            let labels: Vec<(i32, String)> =
193                forms.iter().enumerate().map(|(index, f)| place(index, form_at(f, form))).collect();
194            if fits(&labels, width) {
195                return labels;
196            }
197        }
198        let shortest = longest.saturating_sub(1);
199        for step in 2..=count {
200            let labels: Vec<(i32, String)> = forms
201                .iter()
202                .enumerate()
203                .filter(|(index, _)| index % step == 0)
204                .map(|(index, f)| place(index, form_at(f, shortest)))
205                .collect();
206            if fits(&labels, width) {
207                return labels;
208            }
209        }
210        Vec::new()
211    }
212}
213
214/// The `form`-th form of a label, or its last one when it has fewer.
215fn form_at(forms: &[String], form: usize) -> &str {
216    forms.get(form).or_else(|| forms.last()).map_or("", String::as_str)
217}
218
219/// Whether `labels`, left to right, stay inside `width` cells with at least one empty cell
220/// between two of them. No labels at all do not fit: there is nothing to show.
221fn fits(labels: &[(i32, String)], width: u16) -> bool {
222    if labels.is_empty() {
223        return false;
224    }
225    let mut free_from = 0;
226    for (x, label) in labels {
227        let end = x + i32::from(text::width(label));
228        if *x < free_from || end > i32::from(width) {
229            return false;
230        }
231        free_from = end + 1;
232    }
233    true
234}
235
236/// The hour labels of `span` seconds from `from`: the finest step whose labels fit with the long
237/// form (`09:30`). When that leaves fewer than two labels, which gives a reader no scale, the
238/// finest whole-hour step of the short form (`09`) is taken instead if it writes more.
239fn hour_labels(from: u32, span: u32, width: u16) -> Vec<(i32, String)> {
240    let finest = |short: bool| {
241        HOUR_STEPS
242            .into_iter()
243            .filter(|step| !short || *step >= WHOLE_HOURS)
244            .map(|step| {
245                ticks(from, span, step)
246                    .map(|tick| (time_cell(tick - from, span, width), clock_label(tick % DAY, short)))
247                    // A label that would run past the right edge is left out, not cut.
248                    .filter(|(x, label)| x + i32::from(text::width(label)) <= i32::from(width))
249                    .collect::<Vec<_>>()
250            })
251            .find(|labels| fits(labels, width))
252            .unwrap_or_default()
253    };
254    let long = finest(false);
255    if long.len() >= 2 {
256        return long;
257    }
258    let short = finest(true);
259    if short.len() > long.len() { short } else { long }
260}
261
262/// The clock times a `step` minutes apart that fall inside `span` seconds from `from`, in seconds
263/// on a clock that runs on past midnight.
264fn ticks(from: u32, span: u32, step: u32) -> impl Iterator<Item = u32> {
265    let step = step * 60;
266    let first = from.div_ceil(step) * step;
267    (first..from + span).step_by(usize::try_from(step).unwrap_or(usize::MAX))
268}
269
270/// The label of a clock time: `09:30` in the long form, `09` in the short one.
271pub(crate) fn clock_label(seconds: u32, short: bool) -> String {
272    let time = TimeOfDay::from_seconds_since_midnight(seconds);
273    let hour = format!("{:02}", time.hour);
274    if short {
275        crate::t!("quvyta.time.hour", hour = hour)
276    } else {
277        crate::t!("quvyta.time.clock", hour = hour, minute = format!("{:02}", time.minute))
278    }
279}
280
281impl<Msg: 'static> Widget<Msg> for Axis {
282    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
283        if self.count() == 0 {
284            return Size::default();
285        }
286        Size::new(available.width, 1).min(available)
287    }
288
289    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
290        if area.is_empty() {
291            return;
292        }
293        let color = if self.faint {
294            cx.color("muted")
295        } else {
296            cx.style("axis", None, &[]).color("fg").unwrap_or_else(|| cx.color("dim"))
297        };
298        let style = CellStyle::fg(color);
299        for (x, label) in self.place(area.width) {
300            let width = text::width(&label);
301            cx.text(area.x + x, area.y, &label, style, width);
302        }
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::icons::GlyphMode;
310    use crate::runtime::{App, Command, Harness};
311    use crate::widget::{Length, View};
312    use crate::widgets::{Bar, BarChart};
313
314    struct Demo(Axis);
315
316    impl App for Demo {
317        type Msg = ();
318        fn update(&mut self, _: ()) -> Command<()> {
319            Command::none()
320        }
321        fn view(&self, ui: &mut View<'_, ()>) {
322            ui.add(self.0.clone()).width(Length::Fill(1)).height(Length::Fill(1));
323        }
324    }
325
326    fn screen(axis: Axis, width: u16) -> String {
327        Harness::new(Demo(axis), width, 1).screen().trim_end_matches('\n').to_owned()
328    }
329
330    fn time(hour: u8, minute: u8) -> TimeOfDay {
331        TimeOfDay::new(hour, minute, 0)
332    }
333
334    /// The words of an axis row, in order.
335    fn words(row: &str) -> Vec<&str> {
336        row.split_whitespace().collect()
337    }
338
339    #[test]
340    fn a_day_writes_round_hours_that_fit() {
341        let day = screen(Axis::hours(time(0, 0), time(0, 0)), 72);
342        assert_eq!(
343            words(&day),
344            [
345                "00:00", "02:00", "04:00", "06:00", "08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00",
346                "22:00"
347            ]
348        );
349        assert!(day.starts_with("00:00 "), "{day}");
350        assert_eq!(day.find("12:00"), Some(36), "noon sits in the middle of a day: {day}");
351    }
352
353    #[test]
354    fn narrower_days_thin_the_hours_and_then_shorten_them() {
355        let wide = words(&screen(Axis::hours(time(0, 0), time(0, 0)), 48)).len();
356        let narrow = screen(Axis::hours(time(0, 0), time(0, 0)), 24);
357        assert!(wide < 12 && wide > 4, "48 cells thin the labels to a coarser step: {wide}");
358        assert_eq!(words(&narrow), ["00:00", "06:00", "12:00", "18:00"]);
359        let tiny = screen(Axis::hours(time(0, 0), time(0, 0)), 9);
360        assert_eq!(words(&tiny), ["00", "12"], "too narrow for two long labels, so the hours go short");
361        assert_eq!(words(&screen(Axis::hours(time(0, 0), time(0, 0)), 5)), ["00:00"], "one label is still a label");
362        assert_eq!(screen(Axis::hours(time(0, 0), time(0, 0)), 1), "", "no room for a label writes nothing");
363    }
364
365    #[test]
366    fn a_short_range_goes_down_to_the_quarter_hour() {
367        let morning = screen(Axis::hours(time(9, 0), time(10, 0)), 40);
368        assert_eq!(words(&morning), ["09:00", "09:15", "09:30", "09:45"]);
369        let off_round = screen(Axis::hours(time(9, 10), time(12, 10)), 36);
370        assert!(off_round.starts_with("  "), "a range that starts between labels leaves the start blank: {off_round}");
371        assert_eq!(words(&off_round), ["09:30", "10:00", "10:30", "11:00", "11:30"], "12:00 would run past the edge");
372    }
373
374    #[test]
375    fn a_range_across_midnight_counts_on_into_the_next_day() {
376        let night = screen(Axis::hours(time(22, 0), time(6, 0)), 48);
377        assert_eq!(words(&night), ["22:00", "23:00", "00:00", "01:00", "02:00", "03:00", "04:00", "05:00"]);
378    }
379
380    #[test]
381    fn labels_never_collide_or_get_cut_at_any_width() {
382        let axes = [
383            Axis::hours(time(0, 0), time(0, 0)),
384            Axis::hours(time(9, 10), time(11, 50)),
385            Axis::weekdays(Weekday::Monday, 7),
386            Axis::months(1, 12),
387            Axis::labels(["first week", "second week", "third week"]),
388        ];
389        for axis in axes {
390            for width in 0..90 {
391                let h = Harness::new(Demo(axis.clone()), width.max(1), 1);
392                let i18n = std::sync::Arc::new(crate::i18n::I18n::builtin());
393                let labels = crate::i18n::scope(i18n, || axis.place(width));
394                let row = h.screen();
395                for (x, label) in &labels {
396                    assert!(*x >= 0 && *x + i32::from(text::width(label)) <= i32::from(width), "{axis:?} at {width}");
397                    if width > 0 {
398                        assert!(row.contains(label.as_str()), "{label} is written whole at {width}: {row}");
399                    }
400                }
401                for pair in labels.windows(2) {
402                    let end = pair[0].0 + i32::from(text::width(&pair[0].1));
403                    assert!(pair[1].0 > end, "{axis:?} at {width}: {pair:?} touch");
404                }
405                assert!(!row.contains('…'), "an axis never cuts a label: {row}");
406            }
407        }
408    }
409
410    #[test]
411    fn weekdays_shorten_before_they_thin() {
412        let long = screen(Axis::weekdays(Weekday::Monday, 7), 70);
413        assert_eq!(words(&long), ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]);
414        let short = screen(Axis::weekdays(Weekday::Monday, 7), 28);
415        assert_eq!(words(&short), ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]);
416        let thin = screen(Axis::weekdays(Weekday::Monday, 7), 14);
417        assert_eq!(words(&thin), ["Mo", "We", "Fr", "Su"], "every second day when even the short names crowd");
418        let thinner = screen(Axis::weekdays(Weekday::Monday, 7), 12);
419        assert_eq!(words(&thinner), ["Mo", "Th", "Su"], "every third day when every second still touches");
420        let from_sunday = screen(Axis::weekdays(Weekday::Sunday, 2), 20);
421        assert_eq!(words(&from_sunday), ["Sunday", "Monday"], "the week wraps");
422    }
423
424    #[test]
425    fn months_come_from_the_active_language() {
426        let mut h = Harness::new(Demo(Axis::months(11, 3)), 40, 1);
427        assert_eq!(words(h.screen().trim_end()), ["November", "December", "January"]);
428        h.set_locale("tr");
429        assert_eq!(words(h.screen().trim_end()), ["Kasım", "Aralık", "Ocak"]);
430        let mut year = Harness::new(Demo(Axis::months(1, 12)), 60, 1);
431        assert_eq!(
432            words(year.screen().trim_end()),
433            ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
434        );
435        year.set_locale("tr");
436        assert_eq!(words(year.screen().trim_end())[..3], ["Oca", "Şub", "Mar"]);
437        let mut days = Harness::new(Demo(Axis::weekdays(Weekday::Monday, 2)), 30, 1);
438        days.set_locale("tr");
439        assert_eq!(words(days.screen().trim_end()), ["Pazartesi", "Salı"]);
440    }
441
442    #[test]
443    fn slots_with_the_chart_gap_stand_under_the_bars() {
444        struct Chart;
445        impl App for Chart {
446            type Msg = ();
447            fn update(&mut self, _: ()) -> Command<()> {
448                Command::none()
449            }
450            fn view(&self, ui: &mut View<'_, ()>) {
451                let bars = ["Mo", "Tu", "We", "Th", "Fr"].map(|day| Bar::new(day, 3.0));
452                ui.add(BarChart::new(bars).vertical().gap(2)).width(Length::Fill(1)).height(Length::Cells(4));
453                let days = ["Mo", "Tu", "We", "Th", "Fr"];
454                ui.add(Axis::labels(days).gap(2)).width(Length::Fill(1)).height(Length::Cells(1));
455            }
456        }
457        for width in [23, 30, 41, 56] {
458            let h = Harness::new(Chart, width, 5);
459            let screen = h.screen();
460            let rows: Vec<&str> = screen.lines().collect();
461            assert_eq!(rows[3], rows[4], "the axis writes the chart's own labels in the same cells at {width}");
462        }
463    }
464
465    #[test]
466    fn an_axis_is_text_in_every_glyph_mode() {
467        for mode in [GlyphMode::Nerd, GlyphMode::Unicode, GlyphMode::Ascii] {
468            let mut h = Harness::new(Demo(Axis::hours(time(0, 0), time(0, 0))), 24, 1);
469            h.set_glyph_mode(mode);
470            assert_eq!(words(h.screen().trim_end()), ["00:00", "06:00", "12:00", "18:00"], "{mode:?}");
471            assert_eq!(h.fg(0, 0), h.env().theme().color("dim"), "{mode:?}");
472        }
473    }
474
475    #[test]
476    fn nothing_to_label_measures_nothing() {
477        let h = Harness::new(Demo(Axis::labels(Vec::<String>::new())), 10, 1);
478        assert_eq!(h.screen(), "\n");
479        let axis = Axis::labels(["a"]);
480        assert_eq!(axis.count(), 1);
481        assert_eq!(Axis::weekdays(Weekday::Monday, 0).count(), 0);
482    }
483}