Skip to main content

qframe/widgets/bar_chart/
mod.rs

1//! Bar charts: values compared side by side, in one series or in several.
2//!
3//! The model an application builds is here, together with the palette, the scale and the input
4//! handling; `layout` works out where the categories and their bars sit and `paint` draws them.
5
6mod layout;
7mod paint;
8#[cfg(test)]
9mod tests;
10
11use crate::color::Rgb;
12use crate::event::{Event, KeyEvent, MouseButton, MouseKind};
13use crate::geometry::{Rect, Size, clamp_u16};
14use crate::keymap::Key;
15use crate::style::CellStyle;
16use crate::theme::{State, Theme};
17use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
18
19use super::IndexMessage;
20
21/// Widest a vertical bar gets, in cells.
22const MAX_BAR_WIDTH: u16 = 6;
23
24/// Horizontal charts drop their labels below this width.
25const LABEL_MIN_WIDTH: u16 = 24;
26
27/// Tones a disabled chart walks through before it repeats.
28const SERIES_TONES: usize = 4;
29
30/// One bar of a [`BarChart`].
31#[derive(Debug, Clone, PartialEq)]
32pub struct Bar {
33    label: String,
34    value: f32,
35    value_text: Option<String>,
36    variant: Option<String>,
37}
38
39impl Bar {
40    /// A bar called `label` at `value`.
41    #[must_use]
42    pub fn new(label: impl Into<String>, value: f32) -> Self {
43        Self { label: label.into(), value: value.max(0.0), value_text: None, variant: None }
44    }
45
46    /// A category of a chart whose values come from its series, so the bar carries only a label.
47    fn category(label: impl Into<String>) -> Self {
48        Self::new(label, 0.0)
49    }
50
51    /// Text shown for the value instead of the number, e.g. "1.2 GiB".
52    #[must_use]
53    pub fn value_text(mut self, text: impl Into<String>) -> Self {
54        self.value_text = Some(text.into());
55        self
56    }
57
58    /// Theme variant of this bar, e.g. `"danger"` for a service over its limit. A bar with a
59    /// variant carries a marker before its value, so its meaning reads without colour.
60    #[must_use]
61    pub fn variant(mut self, variant: impl Into<String>) -> Self {
62        self.variant = Some(variant.into());
63        self
64    }
65}
66
67/// One series of a [`BarChart`]: a name and one value per category.
68///
69/// A series takes its tone from the theme's series palette, never from a colour the application
70/// picks, so charts in different applications of the family read the same. By default the n-th
71/// series takes the n-th tone; [`tone`](Self::tone) pins a series to one tone of the palette, so
72/// a category keeps its colour from one chart to the next however many categories each shows.
73/// Values shorter than the categories count as zero and values past the last category are left
74/// out.
75#[derive(Debug, Clone, PartialEq)]
76pub struct Series {
77    name: String,
78    values: Vec<f32>,
79    tone: Option<usize>,
80}
81
82impl Series {
83    /// A series called `name` with one value per category, in the order of the categories.
84    #[must_use]
85    pub fn new(name: impl Into<String>, values: impl IntoIterator<Item = f32>) -> Self {
86        Self { name: name.into(), values: values.into_iter().map(|value| value.max(0.0)).collect(), tone: None }
87    }
88
89    /// Takes the theme's `index`-th series tone
90    /// ([`Theme::series_color`](crate::theme::Theme::series_color)) instead of the tone of the
91    /// series' position. Give a category the same index everywhere — in every chart and in the
92    /// [`Legend`](super::Legend) beside it, through [`Legend::tones`](super::Legend::tones) — and
93    /// it keeps its colour whichever other categories are shown.
94    #[must_use]
95    pub fn tone(mut self, index: usize) -> Self {
96        self.tone = Some(index);
97        self
98    }
99
100    /// The value of category `category`, zero when the series is shorter than that.
101    fn value(&self, category: usize) -> f32 {
102        self.values.get(category).copied().unwrap_or(0.0)
103    }
104}
105
106/// Bars measured in eighths of a cell, with labels and values.
107///
108/// Horizontal by default: one row per bar, labels on the left, values on the right. Vertical
109/// charts stand bars side by side with the value above and the label below each. Bars scale to
110/// the largest value unless a maximum is given. On narrow areas horizontal charts drop their
111/// labels first; vertical bars get thinner and labels are cut with `…`. ASCII mode fills whole
112/// cells.
113///
114/// A chart of [`Series`] shows several values per category: side by side by default, or as
115/// segments of one bar with [`stacked`](Self::stacked). Series take their tones from the theme,
116/// walking a ramp from the accent towards the faint end so neighbouring shares read apart
117/// without a second accent colour; a horizontal segment writes its series name inside itself
118/// when the name fits, so a stack is not read by colour alone.
119///
120/// The chart answers the pointer and the keyboard once it is given
121/// [`on_select`](Self::on_select): the hovered and the selected category rise on a raised ground,
122/// a horizontal chart marks the selected row with the accent pillar in its own lead cell, and the
123/// arrow keys along the bars' axis (↑/↓ or k/j horizontally, ←/→ or h/l vertically) with Home and
124/// End move the selection. Nothing moves or resizes when a category is hovered or selected: a
125/// chart is not a list, so it never slides.
126///
127/// Style keys: `bar-chart` and `bar-chart.<variant>` (`fill`), `bar-chart-bar` with `hover`,
128/// `selected` and `focus` (`bg`, `pillar`), `bar-chart-label` (`fg`), `bar-chart-value` and
129/// `bar-chart-value.<variant>` (`fg`, `bold`). Series take the theme's `series-<n>` colour
130/// tokens when it has them.
131pub struct BarChart<Msg> {
132    bars: Vec<Bar>,
133    series: Vec<Series>,
134    stacked: bool,
135    vertical: bool,
136    max: Option<f32>,
137    gap: u16,
138    unit: Option<String>,
139    selected: Option<usize>,
140    disabled: bool,
141    on_select: Option<IndexMessage<Msg>>,
142}
143
144impl<Msg> BarChart<Msg> {
145    /// A horizontal chart of `bars`.
146    #[must_use]
147    pub fn new(bars: impl IntoIterator<Item = Bar>) -> Self {
148        Self {
149            bars: bars.into_iter().collect(),
150            series: Vec::new(),
151            stacked: false,
152            vertical: false,
153            max: None,
154            gap: 1,
155            unit: None,
156            selected: None,
157            disabled: false,
158            on_select: None,
159        }
160    }
161
162    /// A chart of the categories `labels` with one value per category in every series of
163    /// `series`.
164    ///
165    /// The series of a category stand next to each other; [`stacked`](Self::stacked) puts them in
166    /// one bar instead. A chart of a single series looks exactly like a chart of plain bars.
167    #[must_use]
168    pub fn series(
169        labels: impl IntoIterator<Item = impl Into<String>>,
170        series: impl IntoIterator<Item = Series>,
171    ) -> Self {
172        let mut chart = Self::new(labels.into_iter().map(Bar::category));
173        chart.series = series.into_iter().collect();
174        chart
175    }
176
177    /// Draws the series as segments of one bar per category instead of bars next to each other.
178    #[must_use]
179    pub fn stacked(mut self) -> Self {
180        self.stacked = true;
181        self
182    }
183
184    /// Stands the bars up side by side.
185    #[must_use]
186    pub fn vertical(mut self) -> Self {
187        self.vertical = true;
188        self
189    }
190
191    /// The value of a full bar, e.g. `100.0` for percentages; the largest value by default. A
192    /// stacked chart scales to the largest category total instead.
193    #[must_use]
194    pub fn max(mut self, max: f32) -> Self {
195        self.max = Some(max);
196        self
197    }
198
199    /// Empty rows (horizontal) or columns (vertical) between bars; 1 by default, so neighbouring
200    /// bars never merge into one block. Vertical charts keep at least one column. The bars of one
201    /// category stand right next to each other, so a group reads as one shape.
202    #[must_use]
203    pub fn gap(mut self, cells: u16) -> Self {
204        self.gap = cells;
205        self
206    }
207
208    /// Unit written after every value the chart itself formats, e.g. `"h"` for hours. A bar with
209    /// its own [`value_text`](Bar::value_text) keeps that text as it is.
210    #[must_use]
211    pub fn unit(mut self, unit: impl Into<String>) -> Self {
212        self.unit = Some(unit.into());
213        self
214    }
215
216    /// The selected category, which rises on a raised ground.
217    #[must_use]
218    pub fn selected(mut self, category: Option<usize>) -> Self {
219        self.selected = category;
220        self
221    }
222
223    /// Greys the chart out: it cannot be focused, hovered or selected.
224    #[must_use]
225    pub fn disabled(mut self, disabled: bool) -> Self {
226        self.disabled = disabled;
227        self
228    }
229
230    /// Message for moving the selection to a category, which turns the chart's pointer and
231    /// keyboard handling on.
232    #[must_use]
233    pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
234        self.on_select = Some(Box::new(message));
235        self
236    }
237
238    /// How many categories the chart shows.
239    fn categories(&self) -> usize {
240        self.bars.len()
241    }
242
243    /// How many bars one category shows: one per series, or one when the series are stacked or
244    /// the chart has plain bars.
245    fn bars_per_category(&self) -> u16 {
246        if self.stacked || self.series.len() < 2 {
247            1
248        } else {
249            clamp_u16(i32::try_from(self.series.len()).unwrap_or(i32::MAX))
250        }
251    }
252
253    /// The value of one series in one category, or the bar's own value in a chart of plain bars.
254    fn value(&self, category: usize, series: usize) -> f32 {
255        match self.series.get(series) {
256            Some(series) => series.value(category),
257            None => self.bars.get(category).map_or(0.0, |bar| bar.value),
258        }
259    }
260
261    /// Every value of a category, one per series.
262    fn values(&self, category: usize) -> Vec<f32> {
263        if self.series.is_empty() {
264            vec![self.value(category, 0)]
265        } else {
266            (0..self.series.len()).map(|series| self.value(category, series)).collect()
267        }
268    }
269
270    /// The sum of a category's series.
271    fn total(&self, category: usize) -> f32 {
272        self.values(category).iter().sum()
273    }
274
275    /// The value a full bar stands for: the given maximum, else the largest value a bar can
276    /// reach, which for stacked series is the largest category total.
277    fn scale(&self) -> f32 {
278        let largest = if self.stacked && !self.series.is_empty() {
279            (0..self.categories()).map(|category| self.total(category)).fold(0.0, f32::max)
280        } else {
281            (0..self.categories()).flat_map(|category| self.values(category)).fold(0.0, f32::max)
282        };
283        let max = self.max.unwrap_or(largest);
284        if max > 0.0 { max } else { 1.0 }
285    }
286
287    fn vertical_gap(&self) -> u16 {
288        self.gap.max(1)
289    }
290
291    fn count(&self) -> u16 {
292        clamp_u16(i32::try_from(self.categories()).unwrap_or(i32::MAX))
293    }
294
295    /// Whether the chart answers the pointer and the keyboard.
296    fn interactive(&self) -> bool {
297        self.on_select.is_some() && !self.disabled && self.categories() > 0
298    }
299
300    /// How far a bar of `value` reaches across `cells`, in eighths of a cell. A value that is
301    /// there at all reaches at least one eighth, so a share next to a much larger one is seen
302    /// rather than read as nothing.
303    fn reached(&self, value: f32, cells: u16) -> u32 {
304        let eighths = super::eighths::eighths(value / self.scale(), cells);
305        if eighths == 0 && value > 0.0 { 1 } else { eighths }
306    }
307
308    /// `value` as text, with the chart's unit when it has one.
309    fn format(&self, value: f32) -> String {
310        let number = crate::i18n::number(f64::from(value), usize::from(value.fract() != 0.0));
311        match &self.unit {
312            Some(unit) => format!("{number} {unit}"),
313            None => number,
314        }
315    }
316
317    /// The value text of a plain bar: its own text or the formatted number, after the marker of a
318    /// bar that carries a variant.
319    fn bar_value(&self, cx: &PaintCx<'_>, bar: &Bar) -> String {
320        let value = bar.value_text.clone().unwrap_or_else(|| self.format(bar.value));
321        match &bar.variant {
322            Some(_) => format!("{} {}", cx.env().icons().glyph("dot"), value),
323            None => value,
324        }
325    }
326
327    /// The tone of one series, or of a plain bar when `series` is `None`.
328    fn fill(&self, cx: &mut PaintCx<'_>, bar: &Bar, series: Option<usize>) -> Rgb {
329        if let Some(index) = series.filter(|_| !self.series.is_empty()) {
330            return series_fill(cx.env().theme(), self.tone_index(index), self.disabled);
331        }
332        if self.disabled {
333            return series_fill(cx.env().theme(), 0, true);
334        }
335        cx.style("bar-chart", bar.variant.as_deref(), &[]).color("fill").unwrap_or_else(|| cx.color("accent"))
336    }
337
338    /// The palette index of the series at `position`: the tone it was pinned to, else its
339    /// position.
340    fn tone_index(&self, position: usize) -> usize {
341        self.series.get(position).and_then(|series| series.tone).unwrap_or(position)
342    }
343
344    /// The style of a bar's value text, faint while the chart is disabled.
345    fn value_style(&self, cx: &mut PaintCx<'_>, bar: &Bar) -> CellStyle {
346        if self.disabled {
347            return CellStyle::fg(cx.color("muted"));
348        }
349        let mut style = cx.style("bar-chart-value", bar.variant.as_deref(), &[]).text();
350        style.bg = None;
351        style
352    }
353
354    /// The style of the labels, faint while the chart is disabled.
355    fn label_style(&self, cx: &mut PaintCx<'_>) -> CellStyle {
356        if self.disabled {
357            return CellStyle::fg(cx.color("muted"));
358        }
359        let mut style = cx.style("bar-chart-label", None, &[]).text();
360        style.bg = None;
361        style
362    }
363
364    /// The states of one category: hovered under the pointer, selected, and focused with it. A
365    /// disabled chart is in no state at all: it answers neither the pointer nor the keyboard.
366    fn states(&self, hovered: bool, category: usize, focused: bool) -> Vec<State> {
367        let mut states = Vec::new();
368        if self.disabled {
369            return states;
370        }
371        if hovered {
372            states.push(State::Hover);
373        }
374        if self.selected == Some(category) {
375            states.push(State::Selected);
376            if focused {
377                states.push(State::Focus);
378            }
379        }
380        states
381    }
382
383    /// Raises the ground of a touched category and, in a horizontal chart, stands the pillar in
384    /// its lead cell. Nothing moves: the ground is drawn behind the same cells the resting
385    /// category uses.
386    fn paint_ground(&self, cx: &mut PaintCx<'_>, rect: Rect, states: &[State]) {
387        if states.is_empty() || rect.is_empty() {
388            return;
389        }
390        let style = cx.style("bar-chart-bar", None, states);
391        let selected = states.contains(&State::Selected);
392        let ground = style.text().bg.unwrap_or_else(|| cx.color(if selected { "active" } else { "raised" }));
393        cx.fill(rect, ground);
394        if self.vertical {
395            return;
396        }
397        let pillar = style.color("pillar").unwrap_or_else(|| {
398            let accent = cx.color("accent");
399            if selected { accent } else { accent.mix(cx.color("active"), 0.45) }
400        });
401        cx.pillar(rect.x, rect.y, pillar);
402    }
403
404    fn select(&self, cx: &mut EventCx<'_, Msg>, category: usize) {
405        if let Some(message) = &self.on_select
406            && self.selected != Some(category)
407        {
408            cx.emit(message(category));
409        }
410    }
411
412    /// Whether the chart keeps room for the pillar of a marked category. A chart given a
413    /// selection keeps it even without `on_select`, so the lead cells are there before anything
414    /// is hovered and nothing moves when something is.
415    fn marks_selection(&self) -> bool {
416        !self.disabled && (self.on_select.is_some() || self.selected.is_some())
417    }
418
419    /// The category a plain key moves to, along the axis the bars run along.
420    fn key_target(&self, key: &KeyEvent) -> Option<usize> {
421        let last = self.categories().checked_sub(1)?;
422        let (back, forward) = if self.vertical {
423            ([Key::Left, Key::Char('h')], [Key::Right, Key::Char('l')])
424        } else {
425            ([Key::Up, Key::Char('k')], [Key::Down, Key::Char('j')])
426        };
427        if back.iter().any(|k| key.is_plain(*k)) {
428            return Some(self.selected.map_or(last, |current| current.saturating_sub(1)));
429        }
430        if forward.iter().any(|k| key.is_plain(*k)) {
431            return Some(self.selected.map_or(0, |current| (current + 1).min(last)));
432        }
433        if key.is_plain(Key::Home) {
434            return Some(0);
435        }
436        if key.is_plain(Key::End) {
437            return Some(last);
438        }
439        None
440    }
441}
442
443/// A theme colour token, black when the theme has no such token, as everywhere else.
444fn token(theme: &Theme, name: &str) -> Rgb {
445    theme.color(name).unwrap_or(Rgb::new(0, 0, 0))
446}
447
448/// The tone of series `index`.
449///
450/// The theme decides first, through its `series-<n>` colour tokens. Without them the tone walks
451/// a ramp from the accent towards the faint end of the theme, which keeps a chart inside the
452/// theme's one accent; the ramp repeats after [`SERIES_TONES`] series, so a legend carries the
453/// meaning of a chart with more series than that. A disabled chart walks a quiet ramp instead.
454fn series_fill(theme: &Theme, index: usize, disabled: bool) -> Rgb {
455    // The same call a `Legend` makes for its n-th name, so a series keeps one tone across the
456    // charts and legends of a page, wrapping after the theme's last series colour.
457    if !disabled {
458        return theme.series_color(index);
459    }
460    // A disabled chart keeps its series apart without colour: a ramp from muted to dim, whose
461    // last step reaches the far end so the tones stay apart in a 256-colour terminal.
462    let step = (index % SERIES_TONES) as f32 / (SERIES_TONES - 1) as f32;
463    token(theme, "muted").mix(token(theme, "dim"), step)
464}
465
466impl<Msg: 'static> Widget<Msg> for BarChart<Msg> {
467    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
468        let count = self.count();
469        if count == 0 {
470            return Size::default();
471        }
472        let size = if self.vertical {
473            Size::new(available.width, 8)
474        } else {
475            let bars = count.saturating_mul(self.bars_per_category());
476            Size::new(available.width, bars.saturating_add(self.gap.saturating_mul(count - 1)))
477        };
478        size.min(available)
479    }
480
481    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
482        if area.is_empty() || self.bars.is_empty() {
483            return;
484        }
485        if self.interactive() {
486            cx.register_hit(area);
487        }
488        if self.vertical {
489            self.paint_vertical(cx, area);
490        } else {
491            self.paint_horizontal(cx, area);
492        }
493    }
494
495    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
496        if !self.interactive() {
497            return false;
498        }
499        let area = cx.area();
500        match event {
501            Event::Key(key) => {
502                let Some(target) = self.key_target(key) else { return false };
503                self.select(cx, target);
504                true
505            }
506            Event::Mouse(mouse) if mouse.kind == MouseKind::Down(MouseButton::Left) => {
507                let Some(category) = self.category_at(area, mouse.x, mouse.y) else { return false };
508                self.select(cx, category);
509                true
510            }
511            _ => false,
512        }
513    }
514
515    fn focusable(&self) -> bool {
516        self.interactive()
517    }
518}