1use super::cells;
4use crate::geometry::{Rect, Size};
5use crate::text;
6use crate::widget::{MeasureCx, PaintCx, Widget};
7
8const SWATCH: u16 = 2;
10
11const GAP: u16 = 1;
13
14const SPACING: u16 = 2;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Legend {
38 names: Vec<String>,
39 tones: Vec<usize>,
40 vertical: bool,
41}
42
43impl Legend {
44 #[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 #[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 #[must_use]
61 pub fn vertical(mut self) -> Self {
62 self.vertical = true;
63 self
64 }
65
66 fn tone_index(&self, position: usize) -> usize {
68 self.tones.get(position).copied().unwrap_or(position)
69 }
70
71 fn item_width(name: &str) -> u16 {
73 cells::sum([SWATCH, GAP, text::width(name)])
74 }
75
76 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 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}