mod layout;
mod paint;
#[cfg(test)]
mod tests;
use crate::color::Rgb;
use crate::event::{Event, KeyEvent, MouseButton, MouseKind};
use crate::geometry::{Rect, Size, clamp_u16};
use crate::keymap::Key;
use crate::style::CellStyle;
use crate::theme::{State, Theme};
use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
use super::IndexMessage;
const MAX_BAR_WIDTH: u16 = 6;
const LABEL_MIN_WIDTH: u16 = 24;
const SERIES_TONES: usize = 4;
#[derive(Debug, Clone, PartialEq)]
pub struct Bar {
label: String,
value: f32,
value_text: Option<String>,
variant: Option<String>,
}
impl Bar {
#[must_use]
pub fn new(label: impl Into<String>, value: f32) -> Self {
Self { label: label.into(), value: value.max(0.0), value_text: None, variant: None }
}
fn category(label: impl Into<String>) -> Self {
Self::new(label, 0.0)
}
#[must_use]
pub fn value_text(mut self, text: impl Into<String>) -> Self {
self.value_text = Some(text.into());
self
}
#[must_use]
pub fn variant(mut self, variant: impl Into<String>) -> Self {
self.variant = Some(variant.into());
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Series {
name: String,
values: Vec<f32>,
tone: Option<usize>,
}
impl Series {
#[must_use]
pub fn new(name: impl Into<String>, values: impl IntoIterator<Item = f32>) -> Self {
Self { name: name.into(), values: values.into_iter().map(|value| value.max(0.0)).collect(), tone: None }
}
#[must_use]
pub fn tone(mut self, index: usize) -> Self {
self.tone = Some(index);
self
}
fn value(&self, category: usize) -> f32 {
self.values.get(category).copied().unwrap_or(0.0)
}
}
pub struct BarChart<Msg> {
bars: Vec<Bar>,
series: Vec<Series>,
stacked: bool,
vertical: bool,
max: Option<f32>,
gap: u16,
unit: Option<String>,
selected: Option<usize>,
disabled: bool,
on_select: Option<IndexMessage<Msg>>,
}
impl<Msg> BarChart<Msg> {
#[must_use]
pub fn new(bars: impl IntoIterator<Item = Bar>) -> Self {
Self {
bars: bars.into_iter().collect(),
series: Vec::new(),
stacked: false,
vertical: false,
max: None,
gap: 1,
unit: None,
selected: None,
disabled: false,
on_select: None,
}
}
#[must_use]
pub fn series(
labels: impl IntoIterator<Item = impl Into<String>>,
series: impl IntoIterator<Item = Series>,
) -> Self {
let mut chart = Self::new(labels.into_iter().map(Bar::category));
chart.series = series.into_iter().collect();
chart
}
#[must_use]
pub fn stacked(mut self) -> Self {
self.stacked = true;
self
}
#[must_use]
pub fn vertical(mut self) -> Self {
self.vertical = true;
self
}
#[must_use]
pub fn max(mut self, max: f32) -> Self {
self.max = Some(max);
self
}
#[must_use]
pub fn gap(mut self, cells: u16) -> Self {
self.gap = cells;
self
}
#[must_use]
pub fn unit(mut self, unit: impl Into<String>) -> Self {
self.unit = Some(unit.into());
self
}
#[must_use]
pub fn selected(mut self, category: Option<usize>) -> Self {
self.selected = category;
self
}
#[must_use]
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
#[must_use]
pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
self.on_select = Some(Box::new(message));
self
}
fn categories(&self) -> usize {
self.bars.len()
}
fn bars_per_category(&self) -> u16 {
if self.stacked || self.series.len() < 2 {
1
} else {
clamp_u16(i32::try_from(self.series.len()).unwrap_or(i32::MAX))
}
}
fn value(&self, category: usize, series: usize) -> f32 {
match self.series.get(series) {
Some(series) => series.value(category),
None => self.bars.get(category).map_or(0.0, |bar| bar.value),
}
}
fn values(&self, category: usize) -> Vec<f32> {
if self.series.is_empty() {
vec![self.value(category, 0)]
} else {
(0..self.series.len()).map(|series| self.value(category, series)).collect()
}
}
fn total(&self, category: usize) -> f32 {
self.values(category).iter().sum()
}
fn scale(&self) -> f32 {
let largest = if self.stacked && !self.series.is_empty() {
(0..self.categories()).map(|category| self.total(category)).fold(0.0, f32::max)
} else {
(0..self.categories()).flat_map(|category| self.values(category)).fold(0.0, f32::max)
};
let max = self.max.unwrap_or(largest);
if max > 0.0 { max } else { 1.0 }
}
fn vertical_gap(&self) -> u16 {
self.gap.max(1)
}
fn count(&self) -> u16 {
clamp_u16(i32::try_from(self.categories()).unwrap_or(i32::MAX))
}
fn interactive(&self) -> bool {
self.on_select.is_some() && !self.disabled && self.categories() > 0
}
fn reached(&self, value: f32, cells: u16) -> u32 {
let eighths = super::eighths::eighths(value / self.scale(), cells);
if eighths == 0 && value > 0.0 { 1 } else { eighths }
}
fn format(&self, value: f32) -> String {
let number = crate::i18n::number(f64::from(value), usize::from(value.fract() != 0.0));
match &self.unit {
Some(unit) => format!("{number} {unit}"),
None => number,
}
}
fn bar_value(&self, cx: &PaintCx<'_>, bar: &Bar) -> String {
let value = bar.value_text.clone().unwrap_or_else(|| self.format(bar.value));
match &bar.variant {
Some(_) => format!("{} {}", cx.env().icons().glyph("dot"), value),
None => value,
}
}
fn fill(&self, cx: &mut PaintCx<'_>, bar: &Bar, series: Option<usize>) -> Rgb {
if let Some(index) = series.filter(|_| !self.series.is_empty()) {
return series_fill(cx.env().theme(), self.tone_index(index), self.disabled);
}
if self.disabled {
return series_fill(cx.env().theme(), 0, true);
}
cx.style("bar-chart", bar.variant.as_deref(), &[]).color("fill").unwrap_or_else(|| cx.color("accent"))
}
fn tone_index(&self, position: usize) -> usize {
self.series.get(position).and_then(|series| series.tone).unwrap_or(position)
}
fn value_style(&self, cx: &mut PaintCx<'_>, bar: &Bar) -> CellStyle {
if self.disabled {
return CellStyle::fg(cx.color("muted"));
}
let mut style = cx.style("bar-chart-value", bar.variant.as_deref(), &[]).text();
style.bg = None;
style
}
fn label_style(&self, cx: &mut PaintCx<'_>) -> CellStyle {
if self.disabled {
return CellStyle::fg(cx.color("muted"));
}
let mut style = cx.style("bar-chart-label", None, &[]).text();
style.bg = None;
style
}
fn states(&self, hovered: bool, category: usize, focused: bool) -> Vec<State> {
let mut states = Vec::new();
if self.disabled {
return states;
}
if hovered {
states.push(State::Hover);
}
if self.selected == Some(category) {
states.push(State::Selected);
if focused {
states.push(State::Focus);
}
}
states
}
fn paint_ground(&self, cx: &mut PaintCx<'_>, rect: Rect, states: &[State]) {
if states.is_empty() || rect.is_empty() {
return;
}
let style = cx.style("bar-chart-bar", None, states);
let selected = states.contains(&State::Selected);
let ground = style.text().bg.unwrap_or_else(|| cx.color(if selected { "active" } else { "raised" }));
cx.fill(rect, ground);
if self.vertical {
return;
}
let pillar = style.color("pillar").unwrap_or_else(|| {
let accent = cx.color("accent");
if selected { accent } else { accent.mix(cx.color("active"), 0.45) }
});
cx.pillar(rect.x, rect.y, pillar);
}
fn select(&self, cx: &mut EventCx<'_, Msg>, category: usize) {
if let Some(message) = &self.on_select
&& self.selected != Some(category)
{
cx.emit(message(category));
}
}
fn marks_selection(&self) -> bool {
!self.disabled && (self.on_select.is_some() || self.selected.is_some())
}
fn key_target(&self, key: &KeyEvent) -> Option<usize> {
let last = self.categories().checked_sub(1)?;
let (back, forward) = if self.vertical {
([Key::Left, Key::Char('h')], [Key::Right, Key::Char('l')])
} else {
([Key::Up, Key::Char('k')], [Key::Down, Key::Char('j')])
};
if back.iter().any(|k| key.is_plain(*k)) {
return Some(self.selected.map_or(last, |current| current.saturating_sub(1)));
}
if forward.iter().any(|k| key.is_plain(*k)) {
return Some(self.selected.map_or(0, |current| (current + 1).min(last)));
}
if key.is_plain(Key::Home) {
return Some(0);
}
if key.is_plain(Key::End) {
return Some(last);
}
None
}
}
fn token(theme: &Theme, name: &str) -> Rgb {
theme.color(name).unwrap_or(Rgb::new(0, 0, 0))
}
fn series_fill(theme: &Theme, index: usize, disabled: bool) -> Rgb {
if !disabled {
return theme.series_color(index);
}
let step = (index % SERIES_TONES) as f32 / (SERIES_TONES - 1) as f32;
token(theme, "muted").mix(token(theme, "dim"), step)
}
impl<Msg: 'static> Widget<Msg> for BarChart<Msg> {
fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
let count = self.count();
if count == 0 {
return Size::default();
}
let size = if self.vertical {
Size::new(available.width, 8)
} else {
let bars = count.saturating_mul(self.bars_per_category());
Size::new(available.width, bars.saturating_add(self.gap.saturating_mul(count - 1)))
};
size.min(available)
}
fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
if area.is_empty() || self.bars.is_empty() {
return;
}
if self.interactive() {
cx.register_hit(area);
}
if self.vertical {
self.paint_vertical(cx, area);
} else {
self.paint_horizontal(cx, area);
}
}
fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
if !self.interactive() {
return false;
}
let area = cx.area();
match event {
Event::Key(key) => {
let Some(target) = self.key_target(key) else { return false };
self.select(cx, target);
true
}
Event::Mouse(mouse) if mouse.kind == MouseKind::Down(MouseButton::Left) => {
let Some(category) = self.category_at(area, mouse.x, mouse.y) else { return false };
self.select(cx, category);
true
}
_ => false,
}
}
fn focusable(&self) -> bool {
self.interactive()
}
}