use crate::atoms::ResponseExt;
use crate::Theme;
use egui::{Color32, Sense, Ui, Vec2};
use egui_cha::ViewCtx;
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum CellState {
#[default]
Idle,
Processing,
Delegated,
Escalated,
Error,
Custom(Color32),
}
impl CellState {
pub fn color(&self, theme: &Theme) -> Color32 {
match self {
CellState::Idle => theme.text_muted.gamma_multiply(0.5),
CellState::Processing => theme.state_info,
CellState::Delegated => theme.state_success,
CellState::Escalated => theme.state_warning,
CellState::Error => theme.state_danger,
CellState::Custom(c) => *c,
}
}
}
#[derive(Debug, Clone)]
pub struct HeatmapCell {
pub state: CellState,
pub label: Option<String>,
pub tooltip: Option<String>,
pub flash: bool,
}
impl HeatmapCell {
pub fn new(state: CellState) -> Self {
Self {
state,
label: None,
tooltip: None,
flash: false,
}
}
pub fn idle() -> Self {
Self::new(CellState::Idle)
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn tooltip(mut self, tooltip: impl Into<String>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
pub fn flash(mut self, flash: bool) -> Self {
self.flash = flash;
self
}
}
impl Default for HeatmapCell {
fn default() -> Self {
Self::idle()
}
}
impl From<CellState> for HeatmapCell {
fn from(state: CellState) -> Self {
Self::new(state)
}
}
pub struct HeatmapGrid<'a, F = fn(usize, usize) -> HeatmapCell>
where
F: Fn(usize, usize) -> HeatmapCell,
{
rows: usize,
cols: usize,
states: Option<&'a [CellState]>,
cell_fn: Option<F>,
cell_size: Option<f32>,
spacing: Option<f32>,
show_labels: bool,
tooltip_fn: Option<Box<dyn Fn(usize, usize) -> String + 'a>>,
}
impl<'a> HeatmapGrid<'a, fn(usize, usize) -> HeatmapCell> {
pub fn new(rows: usize, cols: usize) -> Self {
Self {
rows: rows.max(1),
cols: cols.max(1),
states: None,
cell_fn: None,
cell_size: None,
spacing: None,
show_labels: false,
tooltip_fn: None,
}
}
}
impl<'a, F> HeatmapGrid<'a, F>
where
F: Fn(usize, usize) -> HeatmapCell,
{
pub fn data(mut self, states: &'a [CellState]) -> Self {
self.states = Some(states);
self
}
pub fn cell_size(mut self, size: f32) -> Self {
self.cell_size = Some(size);
self
}
pub fn spacing(mut self, spacing: f32) -> Self {
self.spacing = Some(spacing);
self
}
pub fn show_labels(mut self, show: bool) -> Self {
self.show_labels = show;
self
}
pub fn on_hover(mut self, f: impl Fn(usize, usize) -> String + 'a) -> Self {
self.tooltip_fn = Some(Box::new(f));
self
}
}
impl<'a> HeatmapGrid<'a, fn(usize, usize) -> HeatmapCell> {
pub fn cell<F2>(self, f: F2) -> HeatmapGrid<'a, F2>
where
F2: Fn(usize, usize) -> HeatmapCell,
{
HeatmapGrid {
rows: self.rows,
cols: self.cols,
states: self.states,
cell_fn: Some(f),
cell_size: self.cell_size,
spacing: self.spacing,
show_labels: self.show_labels,
tooltip_fn: self.tooltip_fn,
}
}
}
impl<'a, F> HeatmapGrid<'a, F>
where
F: Fn(usize, usize) -> HeatmapCell,
{
pub fn show(self, ui: &mut Ui) -> Option<(usize, usize)> {
self.show_internal(ui)
}
pub fn show_with<Msg>(
self,
ctx: &mut ViewCtx<'_, Msg>,
on_click: impl Fn(usize, usize) -> Msg,
) {
if let Some((row, col)) = self.show_internal(ctx.ui) {
ctx.emit(on_click(row, col));
}
}
fn show_internal(self, ui: &mut Ui) -> Option<(usize, usize)> {
let theme = Theme::current(ui.ctx());
let time = ui.input(|i| i.time) as f32;
let cell_size = self
.cell_size
.unwrap_or(theme.spacing_md + theme.spacing_xs);
let spacing = self.spacing.unwrap_or(theme.spacing_xs / 3.0);
let total_width =
self.cols as f32 * cell_size + (self.cols.saturating_sub(1)) as f32 * spacing;
let total_height =
self.rows as f32 * cell_size + (self.rows.saturating_sub(1)) as f32 * spacing;
let (grid_rect, _response) =
ui.allocate_exact_size(Vec2::new(total_width, total_height), Sense::hover());
if !ui.is_rect_visible(grid_rect) {
return None;
}
let mut clicked: Option<(usize, usize)> = None;
let mut needs_repaint = false;
for row in 0..self.rows {
for col in 0..self.cols {
let idx = row * self.cols + col;
let cell_data = if let Some(ref cell_fn) = self.cell_fn {
cell_fn(row, col)
} else if let Some(states) = self.states {
if idx < states.len() {
HeatmapCell::new(states[idx])
} else {
HeatmapCell::idle()
}
} else {
HeatmapCell::idle()
};
let x = grid_rect.left() + col as f32 * (cell_size + spacing);
let y = grid_rect.top() + row as f32 * (cell_size + spacing);
let cell_rect = egui::Rect::from_min_size(egui::pos2(x, y), Vec2::splat(cell_size));
let cell_response = ui.interact(cell_rect, ui.id().with(idx), Sense::click());
if cell_response.clicked() {
clicked = Some((row, col));
}
let base_color = cell_data.state.color(&theme);
let color = if cell_data.flash {
needs_repaint = true;
let flash_intensity = (time * 6.0).sin() * 0.3 + 0.7;
Color32::from_rgba_unmultiplied(
(base_color.r() as f32 * flash_intensity) as u8,
(base_color.g() as f32 * flash_intensity) as u8,
(base_color.b() as f32 * flash_intensity) as u8,
base_color.a(),
)
} else {
base_color
};
let rounding = theme.radius_sm * 0.5;
ui.painter().rect_filled(cell_rect, rounding, color);
if cell_response.hovered() {
ui.painter().rect_stroke(
cell_rect,
rounding,
egui::Stroke::new(2.0, theme.primary),
egui::StrokeKind::Inside,
);
}
if self.show_labels && cell_size >= 20.0 {
if let Some(ref label) = cell_data.label {
let font_size = (cell_size * 0.4).min(theme.font_size_xs);
ui.painter().text(
cell_rect.center(),
egui::Align2::CENTER_CENTER,
label,
egui::FontId::proportional(font_size),
theme.text_primary,
);
}
}
if cell_response.hovered() {
let tooltip_text = if let Some(ref t) = cell_data.tooltip {
t.clone()
} else if let Some(ref tooltip_fn) = self.tooltip_fn {
tooltip_fn(row, col)
} else {
format!("[{}, {}]", row, col)
};
cell_response.with_tooltip(tooltip_text);
}
}
}
if needs_repaint {
ui.ctx().request_repaint();
}
clicked
}
}