liora-components 0.1.3

Enterprise-style native GPUI component library for Liora applications.
Documentation
use crate::Text;
use crate::gpui_compat::PixelsExt;
use gpui::{
    App, Background, BorderStyle, Bounds, Component, Corners, Edges, Hsla, IntoElement, Pixels,
    RenderOnce, SharedString, Window, div, point, prelude::*, px, quad, size,
};
use liora_core::Config;

#[derive(Clone, Debug)]
pub struct HeatBarItem {
    pub label: SharedString,
    pub value: f64,
    pub color: Hsla,
}
impl HeatBarItem {
    pub fn new(label: impl Into<SharedString>, value: f64, color: Hsla) -> Self {
        Self {
            label: label.into(),
            value,
            color,
        }
    }
}

#[derive(Clone, Debug)]
pub struct HeatBarLegend {
    pub label: SharedString,
    pub count: usize,
    pub color: Hsla,
}
impl HeatBarLegend {
    pub fn new(label: impl Into<SharedString>, count: usize, color: Hsla) -> Self {
        Self {
            label: label.into(),
            count,
            color,
        }
    }
}

#[derive(Clone, Debug)]
pub struct HeatBarColorRange {
    pub min: f64,
    pub max: f64,
    pub color: Hsla,
}

impl HeatBarColorRange {
    pub fn new(min: f64, max: f64, color: Hsla) -> Self {
        Self { min, max, color }
    }

    pub fn up_to(max: f64, color: Hsla) -> Self {
        Self::new(f64::NEG_INFINITY, max, color)
    }

    pub fn above(min: f64, color: Hsla) -> Self {
        Self::new(min, f64::INFINITY, color)
    }

    fn contains(&self, value: f64) -> bool {
        value >= self.min && value <= self.max
    }
}

#[derive(Clone)]
pub struct HeatBar {
    items: Vec<HeatBarItem>,
    legends: Vec<HeatBarLegend>,
    color_ranges: Vec<HeatBarColorRange>,
    height: Pixels,
    bar_width: Pixels,
    gap: Pixels,
    max_value: Option<f64>,
    show_axis: bool,
    x_labels: Vec<SharedString>,
}

impl HeatBar {
    pub fn new(items: impl IntoIterator<Item = HeatBarItem>) -> Self {
        Self {
            items: items.into_iter().collect(),
            legends: Vec::new(),
            color_ranges: Vec::new(),
            height: px(180.0),
            bar_width: px(4.0),
            gap: px(3.0),
            max_value: None,
            show_axis: true,
            x_labels: Vec::new(),
        }
    }
    pub fn legends(mut self, legends: impl IntoIterator<Item = HeatBarLegend>) -> Self {
        self.legends = legends.into_iter().collect();
        self
    }
    pub fn color_ranges(mut self, ranges: impl IntoIterator<Item = HeatBarColorRange>) -> Self {
        self.color_ranges = ranges.into_iter().collect();
        self
    }
    pub fn height(mut self, height: impl Into<Pixels>) -> Self {
        self.height = height.into().max(px(60.0));
        self
    }
    pub fn bar_width(mut self, width: impl Into<Pixels>) -> Self {
        self.bar_width = width.into().max(px(1.0));
        self
    }
    pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
        self.gap = gap.into().max(px(0.0));
        self
    }
    pub fn max_value(mut self, value: f64) -> Self {
        self.max_value = value.is_finite().then_some(value.max(1.0));
        self
    }
    pub fn show_axis(mut self, show: bool) -> Self {
        self.show_axis = show;
        self
    }
    pub fn x_labels(mut self, labels: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
        self.x_labels = labels.into_iter().map(Into::into).collect();
        self
    }
}

impl RenderOnce for HeatBar {
    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
        let theme = cx.global::<Config>().theme.clone();
        let chart_width = self.bar_width * self.items.len() as f32
            + self.gap * self.items.len().saturating_sub(1) as f32
            + if self.show_axis { px(36.0) } else { px(0.0) };
        let items = self.items.clone();
        let max = self.max_value.unwrap_or_else(|| {
            items
                .iter()
                .map(|i| i.value)
                .fold(0.0_f64, f64::max)
                .max(1.0)
        });
        let bar_width = self.bar_width;
        let gap = self.gap;
        let color_ranges = self.color_ranges.clone();
        let show_axis = self.show_axis;
        let axis_color = theme.neutral.text_3;
        let grid = theme.neutral.divider.opacity(0.55);
        div()
            .flex()
            .flex_col()
            .gap_2()
            .w_full()
            .when(!self.legends.is_empty(), |s| {
                s.child(
                    div()
                        .flex()
                        .gap_4()
                        .children(self.legends.into_iter().map(|legend| {
                            div()
                                .flex()
                                .items_center()
                                .gap_1()
                                .child(div().w(px(8.0)).h(px(8.0)).rounded_full().bg(legend.color))
                                .child(
                                    Text::new(format!("{} {}", legend.label, legend.count))
                                        .size(px(12.0)),
                                )
                        })),
                )
            })
            .child(
                gpui::canvas(
                    |_, _, _| (),
                    move |bounds, _, window, _| {
                        let left_pad = if show_axis { px(30.0) } else { px(0.0) };
                        let top = bounds.top() + px(8.0);
                        let bottom = bounds.bottom() - px(22.0);
                        let plot_h = (bottom - top).max(px(1.0));
                        if show_axis {
                            for tick in [0.0, 0.5, 1.0] {
                                let y = bottom - plot_h * tick;
                                window.paint_quad(gpui::fill(
                                    Bounds::new(
                                        point(bounds.left() + left_pad, y),
                                        size(bounds.size.width - left_pad, px(1.0)),
                                    ),
                                    Background::from(grid),
                                ));
                            }
                        }
                        for (index, item) in items.iter().enumerate() {
                            if !item.value.is_finite() {
                                continue;
                            }
                            let h = (plot_h.as_f32() * (item.value / max).clamp(0.0, 1.0) as f32)
                                .max(1.0);
                            let x = bounds.left() + left_pad + (bar_width + gap) * index as f32;
                            let rect =
                                Bounds::new(point(x, bottom - px(h)), size(bar_width, px(h)));
                            let color = color_ranges
                                .iter()
                                .find(|range| range.contains(item.value))
                                .map_or(item.color, |range| range.color);
                            window.paint_quad(quad(
                                rect,
                                Corners::all(bar_width / 2.0).clamp_radii_for_quad_size(rect.size),
                                Background::from(color),
                                Edges::all(px(0.0)),
                                gpui::transparent_black(),
                                BorderStyle::Solid,
                            ));
                        }
                    },
                )
                .w(chart_width)
                .h(self.height),
            )
            .when(!self.x_labels.is_empty(), |s| {
                s.child(
                    div()
                        .ml(if self.show_axis { px(30.0) } else { px(0.0) })
                        .flex()
                        .justify_between()
                        .text_color(axis_color)
                        .text_size(px(11.0))
                        .children(self.x_labels.into_iter().map(|l| div().child(l))),
                )
            })
    }
}

impl IntoElement for HeatBar {
    type Element = Component<Self>;
    fn into_element(self) -> Self::Element {
        Component::new(self)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn heat_bar_tracks_visual_options() {
        let heat = HeatBar::new([HeatBarItem::new("10:00", 3.0, gpui::red())])
            .max_value(10.0)
            .height(px(120.0))
            .bar_width(px(5.0))
            .gap(px(2.0))
            .show_axis(false)
            .color_ranges([HeatBarColorRange::new(0.0, 5.0, gpui::blue())]);
        assert_eq!(heat.max_value, Some(10.0));
        assert_eq!(heat.height, px(120.0));
        assert_eq!(heat.bar_width, px(5.0));
        assert_eq!(heat.gap, px(2.0));
        assert!(!heat.show_axis);
        assert_eq!(heat.color_ranges.len(), 1);
    }

    #[test]
    fn heat_bar_color_ranges_match_inclusive_bounds() {
        let range = HeatBarColorRange::new(3.0, 7.0, gpui::yellow());
        assert!(!range.contains(2.9));
        assert!(range.contains(3.0));
        assert!(range.contains(7.0));
        assert!(!range.contains(7.1));
    }
}