qframe/widgets/bar_chart/
mod.rs1mod 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
21const MAX_BAR_WIDTH: u16 = 6;
23
24const LABEL_MIN_WIDTH: u16 = 24;
26
27const SERIES_TONES: usize = 4;
29
30#[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 #[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 fn category(label: impl Into<String>) -> Self {
48 Self::new(label, 0.0)
49 }
50
51 #[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 #[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#[derive(Debug, Clone, PartialEq)]
76pub struct Series {
77 name: String,
78 values: Vec<f32>,
79 tone: Option<usize>,
80}
81
82impl Series {
83 #[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 #[must_use]
95 pub fn tone(mut self, index: usize) -> Self {
96 self.tone = Some(index);
97 self
98 }
99
100 fn value(&self, category: usize) -> f32 {
102 self.values.get(category).copied().unwrap_or(0.0)
103 }
104}
105
106pub 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 #[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 #[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 #[must_use]
179 pub fn stacked(mut self) -> Self {
180 self.stacked = true;
181 self
182 }
183
184 #[must_use]
186 pub fn vertical(mut self) -> Self {
187 self.vertical = true;
188 self
189 }
190
191 #[must_use]
194 pub fn max(mut self, max: f32) -> Self {
195 self.max = Some(max);
196 self
197 }
198
199 #[must_use]
203 pub fn gap(mut self, cells: u16) -> Self {
204 self.gap = cells;
205 self
206 }
207
208 #[must_use]
211 pub fn unit(mut self, unit: impl Into<String>) -> Self {
212 self.unit = Some(unit.into());
213 self
214 }
215
216 #[must_use]
218 pub fn selected(mut self, category: Option<usize>) -> Self {
219 self.selected = category;
220 self
221 }
222
223 #[must_use]
225 pub fn disabled(mut self, disabled: bool) -> Self {
226 self.disabled = disabled;
227 self
228 }
229
230 #[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 fn categories(&self) -> usize {
240 self.bars.len()
241 }
242
243 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 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 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 fn total(&self, category: usize) -> f32 {
272 self.values(category).iter().sum()
273 }
274
275 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 fn interactive(&self) -> bool {
297 self.on_select.is_some() && !self.disabled && self.categories() > 0
298 }
299
300 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 fn format(&self, value: f32) -> String {
310 let number = if value.fract() == 0.0 { format!("{value:.0}") } else { format!("{value:.1}") };
311 match &self.unit {
312 Some(unit) => format!("{number} {unit}"),
313 None => number,
314 }
315 }
316
317 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 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 fn tone_index(&self, position: usize) -> usize {
341 self.series.get(position).and_then(|series| series.tone).unwrap_or(position)
342 }
343
344 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 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 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 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 fn marks_selection(&self) -> bool {
416 !self.disabled && (self.on_select.is_some() || self.selected.is_some())
417 }
418
419 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
443fn token(theme: &Theme, name: &str) -> Rgb {
445 theme.color(name).unwrap_or(Rgb::new(0, 0, 0))
446}
447
448fn series_fill(theme: &Theme, index: usize, disabled: bool) -> Rgb {
455 if !disabled {
458 return theme.series_color(index);
459 }
460 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}