Skip to main content

qframe/widgets/
legend.rs

1//! Legends: the names behind the series tones of a chart.
2
3use super::cells;
4use crate::geometry::{Rect, Size};
5use crate::text;
6use crate::widget::{MeasureCx, PaintCx, Widget};
7
8/// Cells of colour that stand for a series.
9const SWATCH: u16 = 2;
10
11/// Cells between a swatch and its name.
12const GAP: u16 = 1;
13
14/// Cells between two named series.
15const SPACING: u16 = 2;
16
17/// The names behind the tones of a chart: a patch of colour and the name it stands for.
18///
19/// The n-th name takes the theme's n-th series tone
20/// ([`Theme::series_color`](crate::theme::Theme::series_color)), which is how every chart picks
21/// its tones, so a legend beside a chart names the same colours the chart drew. A legend is not
22/// decoration: in a stacked bar or a tinted grid the meaning of a tone lives nowhere else, and
23/// the theme carries only five series tones, so a sixth series shares the first one's tone and
24/// the name is the only thing that tells them apart.
25///
26/// [`tones`](Self::tones) pins each name to a tone of the palette instead of its position, the
27/// same index its series was given with [`Series::tone`](super::Series::tone), so a category
28/// keeps its colour however many others are named beside it.
29///
30/// Names lie in a row and wrap onto further rows when the area is too narrow; `vertical` puts
31/// each on its own row. A name that does not fit is cut with `…`. The swatch is two cells of
32/// colour, never a bracketed marker, and it works the same in every glyph mode because it is
33/// made of colour rather than characters.
34///
35/// Style keys: `legend` (`fg` for the names).
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Legend {
38    names: Vec<String>,
39    tones: Vec<usize>,
40    vertical: bool,
41}
42
43impl Legend {
44    /// A legend naming the series of a chart, in the order the chart draws them.
45    #[must_use]
46    pub fn new(names: impl IntoIterator<Item = impl Into<String>>) -> Self {
47        Self { names: names.into_iter().map(Into::into).collect(), tones: Vec::new(), vertical: false }
48    }
49
50    /// The palette index of every name, in the order of the names: the n-th name takes the
51    /// theme's `tones[n]`-th series tone. A name past the end of `tones` keeps the tone of its
52    /// position.
53    #[must_use]
54    pub fn tones(mut self, tones: impl IntoIterator<Item = usize>) -> Self {
55        self.tones = tones.into_iter().collect();
56        self
57    }
58
59    /// Puts every name on its own row, for a legend beside a chart rather than under it.
60    #[must_use]
61    pub fn vertical(mut self) -> Self {
62        self.vertical = true;
63        self
64    }
65
66    /// The palette index of the name at `position`.
67    fn tone_index(&self, position: usize) -> usize {
68        self.tones.get(position).copied().unwrap_or(position)
69    }
70
71    /// The width one name takes: the swatch, a gap and the name itself.
72    fn item_width(name: &str) -> u16 {
73        cells::sum([SWATCH, GAP, text::width(name)])
74    }
75
76    /// Where every name goes in `width` cells: its column and row, in order.
77    ///
78    /// Names are laid out from the left and wrap when the next one would not fit; a name wider
79    /// than the whole width still gets its own row, where it is cut while painting.
80    fn places(&self, width: u16) -> Vec<(u16, u16)> {
81        let mut places = Vec::with_capacity(self.names.len());
82        let (mut x, mut y): (u16, u16) = (0, 0);
83        for name in &self.names {
84            let item = Self::item_width(name);
85            if self.vertical {
86                places.push((0, y));
87                y = y.saturating_add(1);
88                continue;
89            }
90            if x > 0 && cells::sum([x, item]) > width {
91                x = 0;
92                y = y.saturating_add(1);
93            }
94            places.push((x, y));
95            x = cells::sum([x, item, SPACING]);
96        }
97        places
98    }
99}
100
101impl<Msg: 'static> Widget<Msg> for Legend {
102    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
103        if self.names.is_empty() {
104            return Size::default();
105        }
106        let places = self.places(available.width);
107        let rows = places.last().map_or(0, |(_, y)| y.saturating_add(1));
108        let width = places
109            .iter()
110            .zip(&self.names)
111            .map(|((x, _), name)| cells::sum([*x, Self::item_width(name)]))
112            .max()
113            .unwrap_or(0);
114        Size::new(width, rows).min(available)
115    }
116
117    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
118        if area.is_empty() || self.names.is_empty() {
119            return;
120        }
121        let mut style = cx.style("legend", None, &[]).text();
122        style.bg = None;
123        // A theme without the key still gives the names a colour of their own: a cell with none
124        // takes whatever the terminal defaults to, and a dialog dims it into the ground.
125        if style.fg.is_none() {
126            style.fg = Some(cx.color("dim"));
127        }
128        let tones: Vec<crate::color::Rgb> =
129            (0..self.names.len()).map(|index| cx.env().theme().series_color(self.tone_index(index))).collect();
130        for ((x, y), (name, tone)) in self.places(area.width).into_iter().zip(self.names.iter().zip(tones)) {
131            if i32::from(y) >= i32::from(area.height) {
132                break;
133            }
134            let row = area.y + i32::from(y);
135            let left = area.x + i32::from(x);
136            let swatch = SWATCH.min(area.width.saturating_sub(x));
137            cx.fill(Rect::new(left, row, swatch, 1), tone);
138            let text_x = left + i32::from(swatch) + i32::from(GAP);
139            let room = area.right().saturating_sub(text_x);
140            let Ok(room) = u16::try_from(room) else {
141                continue;
142            };
143            if room == 0 {
144                continue;
145            }
146            let shown = text::truncate(name, room);
147            cx.text(text_x, row, &shown, style, room);
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::icons::GlyphMode;
156    use crate::runtime::{App, Command, Harness};
157    use crate::widget::{Length, View};
158
159    struct Demo(Legend);
160
161    impl App for Demo {
162        type Msg = ();
163        fn update(&mut self, _: ()) -> Command<()> {
164            Command::none()
165        }
166        fn view(&self, ui: &mut View<'_, ()>) {
167            ui.add(self.0.clone()).width(Length::Fill(1)).height(Length::Fill(1));
168        }
169    }
170
171    fn harness(legend: Legend, width: u16, height: u16) -> Harness<Demo> {
172        Harness::new(Demo(legend), width, height)
173    }
174
175    #[test]
176    fn names_sit_after_their_series_tone() {
177        let h = harness(Legend::new(["Rust", "Docs", "Review"]), 40, 1);
178        assert_eq!(h.screen(), "   Rust     Docs     Review\n");
179        let theme = h.env().theme();
180        assert_eq!(h.bg(0, 0), Some(theme.series_color(0)), "the first swatch is the first series tone");
181        assert_eq!(h.bg(1, 0), Some(theme.series_color(0)), "the swatch is two cells wide");
182        assert_eq!(h.bg(9, 0), Some(theme.series_color(1)));
183        assert_eq!(h.bg(18, 0), Some(theme.series_color(2)));
184        assert_ne!(theme.series_color(0), theme.series_color(1));
185    }
186
187    #[test]
188    fn a_narrow_area_wraps_and_then_cuts() {
189        let h = harness(Legend::new(["Rust", "Docs", "Review"]), 18, 3);
190        assert_eq!(h.screen(), "   Rust     Docs\n   Review\n\n");
191        let narrow = harness(Legend::new(["Rust", "Docs"]), 10, 2);
192        assert_eq!(narrow.screen(), "   Rust\n   Docs\n", "one name a row when only one fits");
193        let cut = harness(Legend::new(["Refactoring"]), 8, 1);
194        assert_eq!(cut.screen(), "   Refa…\n");
195    }
196
197    #[test]
198    fn vertical_puts_one_name_on_each_row() {
199        let h = harness(Legend::new(["Rust", "Docs"]).vertical(), 20, 2);
200        assert_eq!(h.screen(), "   Rust\n   Docs\n");
201        let theme = h.env().theme();
202        assert_eq!(h.bg(0, 1), Some(theme.series_color(1)));
203    }
204
205    #[test]
206    fn the_swatch_is_colour_in_every_glyph_mode() {
207        for mode in [GlyphMode::Nerd, GlyphMode::Unicode, GlyphMode::Ascii] {
208            let mut h = harness(Legend::new(["Rust"]), 12, 1);
209            h.set_glyph_mode(mode);
210            assert_eq!(h.screen(), "   Rust\n", "{mode:?}");
211            assert_eq!(h.bg(0, 0), Some(h.env().theme().series_color(0)), "{mode:?}");
212        }
213    }
214
215    #[test]
216    fn pinned_tones_follow_the_category_not_the_position() {
217        let h = harness(Legend::new(["Docs", "Review"]).tones([1, 2]), 40, 1);
218        let theme = h.env().theme();
219        assert_eq!(h.screen(), "   Docs     Review\n", "the names sit exactly where they always do");
220        assert_eq!(h.bg(0, 0), Some(theme.series_color(1)), "Docs keeps its own tone without Rust beside it");
221        assert_eq!(h.bg(9, 0), Some(theme.series_color(2)));
222        let short = harness(Legend::new(["Docs", "Review"]).tones([4]), 40, 1);
223        assert_eq!(short.bg(0, 0), Some(theme.series_color(4)));
224        assert_eq!(short.bg(9, 0), Some(theme.series_color(1)), "a name past the tones keeps its position's tone");
225    }
226
227    #[test]
228    fn tones_that_match_the_positions_draw_the_same_legend() {
229        for (width, height) in [(40, 1), (18, 3), (8, 1)] {
230            let plain = harness(Legend::new(["Rust", "Docs", "Review"]), width, height);
231            let pinned = harness(Legend::new(["Rust", "Docs", "Review"]).tones([0, 1, 2]), width, height);
232            assert_eq!(plain.buffer(), pinned.buffer(), "{width}×{height}");
233        }
234    }
235
236    #[test]
237    fn nothing_to_name_draws_nothing_and_tiny_areas_survive() {
238        let empty = harness(Legend::new(Vec::<String>::new()), 10, 1);
239        assert_eq!(empty.screen(), "\n");
240        for (width, height) in [(1, 1), (2, 1), (3, 1), (4, 2)] {
241            let h = harness(Legend::new(["Rust", "Docs"]), width, height);
242            assert_eq!(h.screen().lines().count(), usize::from(height), "{width}×{height}");
243        }
244    }
245}