use crate::core::{Color, Font, HorizontalAlignment, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::capability::coercion::expect_bool;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
const RAMP_LOW: Color = Color { r: 33, g: 68, b: 121, a: 255 };
const RAMP_MID: Color = Color { r: 84, g: 172, b: 158, a: 255 };
const RAMP_HIGH: Color = Color { r: 249, g: 216, b: 122, a: 255 };
const CELL_INSET: i32 = 1;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HeatmapCell {
pub value: Option<f64>,
}
impl HeatmapCell {
pub fn new(value: f64) -> Self {
Self { value: if value.is_finite() { Some(value) } else { None } }
}
pub fn empty() -> Self {
Self { value: None }
}
}
pub struct Heatmap {
base: BaseWidget,
row_labels: Vec<String>,
column_labels: Vec<String>,
values: Vec<Vec<HeatmapCell>>,
scale_minimum: Option<f64>,
scale_maximum: Option<f64>,
show_labels: bool,
show_legend: bool,
hovered_cell: Option<(usize, usize)>,
pub cell_hovered: Signal1<(usize, usize)>,
pub cell_clicked: Signal1<(usize, usize)>,
}
impl Heatmap {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Heatmap, geometry, "Heatmap"),
row_labels: Vec::new(),
column_labels: Vec::new(),
values: Vec::new(),
scale_minimum: None,
scale_maximum: None,
show_labels: true,
show_legend: true,
hovered_cell: None,
cell_hovered: Signal1::new(),
cell_clicked: Signal1::new(),
}
}
pub fn set_data(
&mut self,
row_labels: Vec<String>,
column_labels: Vec<String>,
values: Vec<Vec<HeatmapCell>>,
) {
let columns = column_labels.len();
let rows = row_labels.len();
self.values = values.into_iter().take(rows).map(|row| pad_row(row, columns)).collect();
while self.values.len() < rows {
self.values.push(pad_row(Vec::new(), columns));
}
self.row_labels = row_labels;
self.column_labels = column_labels;
self.hovered_cell = None;
self.base.request_redraw();
}
pub fn row_labels(&self) -> &[String] {
&self.row_labels
}
pub fn column_labels(&self) -> &[String] {
&self.column_labels
}
pub fn row_count(&self) -> usize {
self.row_labels.len()
}
pub fn column_count(&self) -> usize {
self.column_labels.len()
}
pub fn cell(&self, row: usize, column: usize) -> Option<HeatmapCell> {
self.values.get(row)?.get(column).copied()
}
pub fn set_cell(&mut self, row: usize, column: usize, value: f64) -> bool {
let Some(cells) = self.values.get_mut(row) else {
return false;
};
let Some(cell) = cells.get_mut(column) else {
return false;
};
*cell = HeatmapCell::new(value);
self.base.request_redraw();
true
}
pub fn clear_cell(&mut self, row: usize, column: usize) -> bool {
let Some(cells) = self.values.get_mut(row) else {
return false;
};
let Some(cell) = cells.get_mut(column) else {
return false;
};
*cell = HeatmapCell::empty();
self.base.request_redraw();
true
}
pub fn scale_minimum(&self) -> Option<f64> {
self.scale_minimum
}
pub fn set_scale_minimum(&mut self, minimum: Option<f64>) {
self.scale_minimum = minimum.filter(|value| value.is_finite());
self.base.request_redraw();
}
pub fn scale_maximum(&self) -> Option<f64> {
self.scale_maximum
}
pub fn set_scale_maximum(&mut self, maximum: Option<f64>) {
self.scale_maximum = maximum.filter(|value| value.is_finite());
self.base.request_redraw();
}
pub fn show_labels(&self) -> bool {
self.show_labels
}
pub fn set_show_labels(&mut self, show: bool) {
self.show_labels = show;
self.base.request_redraw();
}
pub fn show_legend(&self) -> bool {
self.show_legend
}
pub fn set_show_legend(&mut self, show: bool) {
self.show_legend = show;
self.base.request_redraw();
}
pub fn scale_range(&self) -> Option<(f64, f64)> {
let derived = self.data_range();
let low = self.scale_minimum.or(derived.map(|(low, _)| low));
let high = self.scale_maximum.or(derived.map(|(_, high)| high));
match (low, high) {
(Some(low), Some(high)) => Some((low, high)),
_ => None,
}
}
fn data_range(&self) -> Option<(f64, f64)> {
let mut low = f64::INFINITY;
let mut high = f64::NEG_INFINITY;
let mut seen = false;
for row in &self.values {
for cell in row {
if let Some(value) = cell.value {
low = low.min(value);
high = high.max(value);
seen = true;
}
}
}
if seen {
Some((low, high))
} else {
None
}
}
fn color_for_value(&self, value: f64) -> Color {
let Some((low, high)) = self.scale_range() else {
return RAMP_MID;
};
let span = high - low;
if span <= 0.0 {
return RAMP_HIGH;
}
let fraction = ((value - low) / span).clamp(0.0, 1.0) as f32;
interpolate_ramp(fraction)
}
fn grid_rect(&self) -> Rect {
let full = self.geometry();
let left = if self.show_labels { LABEL_GUTTER } else { 0 };
let top = if self.show_labels { LABEL_GUTTER } else { 0 };
let bottom = if self.show_legend { LEGEND_HEIGHT } else { 0 };
let width = (full.width as i32 - left).max(0) as u32;
let height = (full.height as i32 - top - bottom).max(0) as u32;
Rect { x: full.x + left, y: full.y + top, width, height }
}
fn cell_at(&self, pos: Point) -> Option<(usize, usize)> {
let rows = self.row_count();
let columns = self.column_count();
if rows == 0 || columns == 0 {
return None;
}
let grid = self.grid_rect();
if grid.width == 0 || grid.height == 0 {
return None;
}
let dx = pos.x - grid.x;
let dy = pos.y - grid.y;
if dx < 0 || dy < 0 || dx >= grid.width as i32 || dy >= grid.height as i32 {
return None;
}
let column = (dx as u64 * columns as u64 / grid.width as u64) as usize;
let row = (dy as u64 * rows as u64 / grid.height as u64) as usize;
Some((row.min(rows - 1), column.min(columns - 1)))
}
}
const LABEL_GUTTER: i32 = 56;
const LEGEND_HEIGHT: i32 = RAMP_HEIGHT + RAMP_GAP + LEGEND_LABEL_HEIGHT;
const RAMP_HEIGHT: i32 = 8;
const RAMP_GAP: i32 = 2;
const LEGEND_LABEL_HEIGHT: i32 = 10;
fn pad_row(mut row: Vec<HeatmapCell>, columns: usize) -> Vec<HeatmapCell> {
row.truncate(columns);
while row.len() < columns {
row.push(HeatmapCell::empty());
}
row
}
fn interpolate_ramp(fraction: f32) -> Color {
let fraction = if fraction.is_nan() { 0.0 } else { fraction.clamp(0.0, 1.0) };
let (from, to, local) = if fraction <= 0.5 {
(RAMP_LOW, RAMP_MID, fraction * 2.0)
} else {
(RAMP_MID, RAMP_HIGH, (fraction - 0.5) * 2.0)
};
let blend = |a: u8, b: u8| -> u8 {
let value = a as f32 + (b as f32 - a as f32) * local;
value.round().clamp(0.0, 255.0) as u8
};
Color { r: blend(from.r, to.r), g: blend(from.g, to.g), b: blend(from.b, to.b), a: 255 }
}
impl Widget for Heatmap {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
let columns = if self.column_count() == 0 { DEFAULT_COLUMNS } else { self.column_count() };
let rows = if self.row_count() == 0 { DEFAULT_ROWS } else { self.row_count() };
Size::new(
columns as u32 * MIN_CELL_SIZE + LABEL_GUTTER as u32,
rows as u32 * MIN_CELL_SIZE + LABEL_GUTTER as u32 + LEGEND_HEIGHT as u32,
)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
const DEFAULT_COLUMNS: usize = 4;
const DEFAULT_ROWS: usize = 3;
const MIN_CELL_SIZE: u32 = 12;
impl WidgetProperties for Heatmap {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"row_count" => Ok(CapabilityValue::UInt(self.row_count() as u64)),
"column_count" => Ok(CapabilityValue::UInt(self.column_count() as u64)),
"scale_minimum" => Ok(match self.scale_minimum() {
Some(value) => CapabilityValue::Float(value),
None => CapabilityValue::Null,
}),
"scale_maximum" => Ok(match self.scale_maximum() {
Some(value) => CapabilityValue::Float(value),
None => CapabilityValue::Null,
}),
"resolved_minimum" => Ok(match self.scale_range() {
Some((low, _)) => CapabilityValue::Float(low),
None => CapabilityValue::Null,
}),
"resolved_maximum" => Ok(match self.scale_range() {
Some((_, high)) => CapabilityValue::Float(high),
None => CapabilityValue::Null,
}),
"show_labels" => Ok(CapabilityValue::Bool(self.show_labels())),
"show_legend" => Ok(CapabilityValue::Bool(self.show_legend())),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"scale_minimum" => {
match value {
CapabilityValue::Null => self.set_scale_minimum(None),
other => self.set_scale_minimum(Some(expect_float(other)?)),
}
Ok(())
}
"scale_maximum" => {
match value {
CapabilityValue::Null => self.set_scale_maximum(None),
other => self.set_scale_maximum(Some(expect_float(other)?)),
}
Ok(())
}
"show_labels" => {
self.set_show_labels(expect_bool(value)?);
Ok(())
}
"show_legend" => {
self.set_show_legend(expect_bool(value)?);
Ok(())
}
"row_count" | "column_count" | "resolved_minimum" | "resolved_maximum" => {
Err(CapabilityAccessError::ReadOnlyProperty)
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"row_count",
"column_count",
"scale_minimum",
"scale_maximum",
"resolved_minimum",
"resolved_maximum",
"show_labels",
"show_legend",
BASE_PROPERTY_NAMES
]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"clear" => {
for row in &mut self.values {
for cell in row {
*cell = HeatmapCell::empty();
}
}
self.hovered_cell = None;
self.base.request_redraw();
Ok(())
}
"set_data" | "set_cell" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
fn expect_float(value: CapabilityValue) -> Result<f64, CapabilityAccessError> {
match value {
CapabilityValue::Float(float) => Ok(float),
CapabilityValue::Int(int) => Ok(int as f64),
CapabilityValue::UInt(uint) => Ok(uint as f64),
CapabilityValue::String(text) => {
text.parse::<f64>().map_err(|_| CapabilityAccessError::TypeMismatch)
}
_ => Err(CapabilityAccessError::TypeMismatch),
}
}
impl Draw for Heatmap {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
let style = self.style();
let background = style.background_color.unwrap_or(Color::rgb(30, 30, 30));
let border = style.border_color.unwrap_or(Color::rgb(130, 130, 130));
let text_color = style.text_color.unwrap_or(Color::rgb(225, 225, 225));
let default_font = Font::default();
let font = style.font.as_ref().unwrap_or(&default_font);
context.fill_rect(rect, background);
if let Some(border_color) = style.border_color {
context.draw_rect(rect, border_color);
}
let rows = self.row_count();
let columns = self.column_count();
let grid = self.grid_rect();
if rows == 0 || columns == 0 || grid.width == 0 || grid.height == 0 {
context.draw_text(
Point::new(rect.x + 6, rect.y + rect.height as i32 / 2),
"Heatmap",
font,
text_color,
HorizontalAlignment::Left,
);
return;
}
let cell_width = grid.width as i32 / columns as i32;
let cell_height = grid.height as i32 / rows as i32;
if cell_width <= 0 || cell_height <= 0 {
return;
}
for row in 0..rows {
let y = grid.y + row as i32 * cell_height;
for column in 0..columns {
let x = grid.x + column as i32 * cell_width;
let width = if column + 1 == columns {
grid.width as i32 - column as i32 * cell_width
} else {
cell_width
};
let height = if row + 1 == rows {
grid.height as i32 - row as i32 * cell_height
} else {
cell_height
};
let cell_rect = Rect {
x: x + CELL_INSET,
y: y + CELL_INSET,
width: (width - CELL_INSET * 2).max(1) as u32,
height: (height - CELL_INSET * 2).max(1) as u32,
};
let value = self.cell(row, column).and_then(|cell| cell.value);
let fill = match value {
Some(value) => self.color_for_value(value),
None => background,
};
context.fill_rect(cell_rect, fill);
if self.hovered_cell == Some((row, column)) {
context.draw_rect(cell_rect, border);
}
}
}
if self.show_labels {
self.draw_labels(context, grid, font, text_color, cell_height);
}
if self.show_legend {
self.draw_legend(context, rect, grid, font, text_color);
}
}
}
impl Heatmap {
fn draw_labels(
&self,
context: &mut RenderContext,
grid: Rect,
font: &Font,
text_color: Color,
cell_height: i32,
) {
let rows = self.row_count();
let columns = self.column_count();
let cell_width = grid.width as i32 / columns.max(1) as i32;
let font_height = context.measure_text("M", font).height as i32;
for row in 0..rows {
let y = grid.y + row as i32 * cell_height + cell_height / 2;
context.draw_text_fitted(
Rect::new(grid.x - LABEL_GUTTER, y, LABEL_GUTTER as u32, font_height.max(1) as u32),
&self.row_labels[row],
font,
text_color,
HorizontalAlignment::Left,
);
}
let full = self.geometry();
for column in 0..columns {
let cell_x = grid.x + column as i32 * cell_width;
let width = if column + 1 == columns {
grid.width as i32 - column as i32 * cell_width
} else {
cell_width
};
let top = (grid.y - font_height).max(full.y);
let height = (grid.y - top).max(1) as u32;
context.draw_text_fitted(
Rect::new(cell_x, top, width.max(1) as u32, height),
&self.column_labels[column],
font,
text_color,
HorizontalAlignment::Center,
);
}
}
fn draw_legend(
&self,
context: &mut RenderContext,
rect: Rect,
grid: Rect,
font: &Font,
text_color: Color,
) {
let strip_height = RAMP_HEIGHT;
let y = rect.y + rect.height as i32 - LEGEND_HEIGHT + RAMP_GAP / 2;
let strip_x = grid.x;
let strip_width = grid.width as i32;
if strip_width <= 0 {
return;
}
let steps = strip_width.min(512);
let segment = (strip_width as f32 / steps as f32).max(1.0);
for step in 0..steps {
let fraction = step as f32 / (steps - 1).max(1) as f32;
let x = strip_x + (step as f32 * segment).round() as i32;
let width = segment.round().max(1.0) as u32;
context.fill_rect(
Rect { x, y, width, height: strip_height as u32 },
interpolate_ramp(fraction),
);
}
let Some((low, high)) = self.scale_range() else {
return;
};
let label_height = context.measure_text("0", font).height;
let band_top = rect.y + rect.height as i32 - LEGEND_HEIGHT;
let band_bottom = band_top + LEGEND_HEIGHT;
let label_top = (band_bottom - label_height as i32).max(band_top);
let label_band_height = label_height.max(1);
context.draw_text_fitted(
Rect::new(strip_x, label_top, strip_width.max(1) as u32, label_band_height),
&format_endpoint(low),
font,
text_color,
HorizontalAlignment::Left,
);
let high_text = format_endpoint(high);
context.draw_text_fitted(
Rect::new(strip_x, label_top, strip_width.max(1) as u32, label_band_height),
&high_text,
font,
text_color,
HorizontalAlignment::Right,
);
}
}
fn format_endpoint(value: f64) -> String {
if value.is_finite() && value.fract() == 0.0 && value.abs() < 1.0e15 {
format!("{}", value as i64)
} else {
format!("{value:.2}")
}
}
impl EventHandler for Heatmap {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MouseMove { pos, .. } => {
let cell = self.cell_at(*pos);
if cell != self.hovered_cell {
self.hovered_cell = cell;
if let Some((row, column)) = cell {
self.cell_hovered.emit((row, column));
}
self.base.request_redraw();
}
}
Event::MousePress { pos, button } if *button == 1 => {
if let Some((row, column)) = self.cell_at(*pos) {
self.cell_clicked.emit((row, column));
}
}
#[cfg(feature = "touch")]
Event::TouchBegin { pos, .. } => {
if let Some((row, column)) = self.cell_at(*pos) {
self.cell_clicked.emit((row, column));
}
}
Event::MouseLeave { .. } if self.hovered_cell.take().is_some() => {
self.base.request_redraw();
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Point;
use crate::render::{PaintBackend, SoftwarePaintBackend};
fn sample() -> Heatmap {
let mut heatmap = Heatmap::new(Rect::new(0, 0, 240, 140));
let rows = vec!["R1".to_string(), "R2".to_string(), "R3".to_string()];
let columns = vec!["C1".to_string(), "C2".to_string(), "C3".to_string(), "C4".to_string()];
let values = vec![
vec![
HeatmapCell::new(1.0),
HeatmapCell::new(2.0),
HeatmapCell::new(3.0),
HeatmapCell::new(4.0),
],
vec![
HeatmapCell::new(5.0),
HeatmapCell::new(6.0),
HeatmapCell::new(7.0),
HeatmapCell::new(8.0),
],
vec![
HeatmapCell::new(9.0),
HeatmapCell::new(10.0),
HeatmapCell::new(11.0),
HeatmapCell::new(12.0),
],
];
heatmap.set_data(rows, columns, values);
heatmap
}
fn ink(widget: &mut Heatmap, fill: Color) -> Vec<(u8, u8, u8)> {
let mut backend = SoftwarePaintBackend::new(Size::new(240, 140), 1.0);
backend.begin_frame(fill);
let mut ctx = crate::render::RenderContext::new(&mut backend);
widget.draw(&mut ctx);
backend.end_frame();
backend
.frame_rgba()
.chunks_exact(4)
.filter(|px| (px[0], px[1], px[2]) != (fill.r, fill.g, fill.b))
.map(|px| (px[0], px[1], px[2]))
.collect()
}
fn set_background(widget: &mut Heatmap, background: Color) {
let style =
crate::style::WidgetStyle { background_color: Some(background), ..Default::default() };
widget.base_mut().set_style(style);
}
#[test]
fn heatmap_draw_paints_cells() {
let mut heatmap = sample();
let painted = ink(&mut heatmap, Color::rgb(255, 0, 255));
assert!(
painted.len() > 1000,
"a filled 3x4 grid must paint far more than a frame; got {} pixels",
painted.len()
);
let mut distinct: Vec<(u8, u8, u8)> = painted.clone();
distinct.sort_unstable();
distinct.dedup();
assert!(
distinct.len() >= 6,
"a 12-cell ramp over a 12-value range must produce several colours, got {}",
distinct.len()
);
}
#[test]
fn heatmap_ramp_is_data_not_chrome() {
let mut styled = sample();
set_background(&mut styled, Color::rgb(250, 250, 250));
let mut other = sample();
set_background(&mut other, Color::rgb(10, 10, 10));
for value in [1.0, 3.0, 7.0, 12.0] {
assert_eq!(
styled.color_for_value(value),
other.color_for_value(value),
"the ramp must not move with the theme — it encodes the value"
);
}
assert_eq!(styled.color_for_value(1.0), RAMP_LOW);
assert_eq!(styled.color_for_value(12.0), RAMP_HIGH);
let light = ink(&mut styled, Color::rgb(255, 0, 255));
let dark = ink(&mut other, Color::rgb(255, 0, 255));
assert_ne!(
light, dark,
"the themed frame must change with the style even though the cells do not"
);
assert!(light.contains(&(250, 250, 250)), "the light frame must paint its background");
assert!(dark.contains(&(10, 10, 10)), "the dark frame must paint its background");
}
#[test]
fn heatmap_cell_is_addressed_by_coordinate_pair() {
let heatmap = sample();
assert_eq!(heatmap.cell(0, 0).unwrap().value, Some(1.0));
assert_eq!(heatmap.cell(2, 3).unwrap().value, Some(12.0));
assert_eq!(heatmap.cell(1, 2).unwrap().value, Some(7.0));
assert_eq!(heatmap.cell(3, 0), None, "past the last row");
assert_eq!(heatmap.cell(0, 4), None, "past the last column");
}
#[test]
fn heatmap_distinguishes_no_data_from_the_minimum() {
let mut heatmap = Heatmap::new(Rect::new(0, 0, 240, 140));
set_background(&mut heatmap, Color::rgb(30, 30, 30));
heatmap.set_data(
vec!["R1".to_string()],
vec!["C1".to_string(), "C2".to_string()],
vec![vec![HeatmapCell::new(0.0), HeatmapCell::empty()]],
);
assert_eq!(heatmap.cell(0, 0).unwrap().value, Some(0.0));
assert_eq!(heatmap.cell(0, 1).unwrap().value, None);
let present = heatmap.color_for_value(0.0);
let background = Color::rgb(30, 30, 30);
assert_ne!(present, background, "a present value must be visible against the frame");
}
#[test]
fn heatmap_rejects_non_finite_values() {
assert_eq!(HeatmapCell::new(f64::NAN).value, None);
assert_eq!(HeatmapCell::new(f64::INFINITY).value, None);
assert_eq!(HeatmapCell::new(f64::NEG_INFINITY).value, None);
let mut heatmap = sample();
assert!(heatmap.set_cell(0, 0, f64::NAN));
assert_eq!(heatmap.cell(0, 0).unwrap().value, None, "NaN must clear, not store");
assert!(!heatmap.set_cell(9, 9, 1.0), "an out-of-range write reports failure");
assert!(!heatmap.clear_cell(9, 9), "and so does an out-of-range clear");
}
#[test]
fn heatmap_pinned_scale_maps_equal_values_to_equal_colours() {
let mut narrow = Heatmap::new(Rect::new(0, 0, 240, 140));
narrow.set_data(
vec!["R1".to_string()],
vec!["C1".to_string(), "C2".to_string()],
vec![vec![HeatmapCell::new(4.0), HeatmapCell::new(6.0)]],
);
let mut wide = Heatmap::new(Rect::new(0, 0, 240, 140));
wide.set_data(
vec!["R1".to_string()],
vec!["C1".to_string(), "C2".to_string()],
vec![vec![HeatmapCell::new(0.0), HeatmapCell::new(10.0)]],
);
assert_ne!(
narrow.color_for_value(4.5),
wide.color_for_value(4.5),
"the same value must sit at different fractions of two derived ranges"
);
narrow.set_scale_minimum(Some(0.0));
narrow.set_scale_maximum(Some(10.0));
wide.set_scale_minimum(Some(0.0));
wide.set_scale_maximum(Some(10.0));
for value in [0.0, 2.5, 4.5, 5.0, 7.5, 10.0] {
assert_eq!(
narrow.color_for_value(value),
wide.color_for_value(value),
"on a shared scale {value} must be the same colour on both"
);
}
assert_eq!(narrow.scale_range(), Some((0.0, 10.0)));
assert_eq!(narrow.color_for_value(5.0), RAMP_MID, "the midpoint of the ramp");
assert_eq!(narrow.color_for_value(0.0), RAMP_LOW);
assert_eq!(narrow.color_for_value(10.0), RAMP_HIGH);
}
#[test]
fn heatmap_empty_grid_has_no_scale() {
let heatmap = Heatmap::new(Rect::new(0, 0, 240, 140));
assert_eq!(heatmap.scale_range(), None);
let mut with_labels = Heatmap::new(Rect::new(0, 0, 240, 140));
with_labels.set_data(
vec!["R1".to_string()],
vec!["C1".to_string()],
vec![vec![HeatmapCell::empty()]],
);
assert_eq!(with_labels.scale_range(), None, "labels alone are not a range");
}
#[test]
fn heatmap_values_outside_a_pinned_range_clamp() {
let mut heatmap = sample();
heatmap.set_scale_minimum(Some(4.0));
heatmap.set_scale_maximum(Some(8.0));
assert_eq!(heatmap.color_for_value(1.0), RAMP_LOW, "below the floor clamps to low");
assert_eq!(heatmap.color_for_value(12.0), RAMP_HIGH, "above the ceiling clamps to high");
}
#[test]
fn heatmap_pointer_maps_to_the_cell_under_it() {
let heatmap = sample();
let grid = heatmap.grid_rect();
for row in 0..3usize {
for column in 0..4usize {
let x = grid.x + (column as u32 * grid.width / 4) as i32 + 2;
let y = grid.y + (row as u32 * grid.height / 3) as i32 + 2;
assert_eq!(
heatmap.cell_at(Point::new(x, y)),
Some((row, column)),
"the centre of cell ({row}, {column}) must resolve to it"
);
}
}
assert_eq!(heatmap.cell_at(Point::new(grid.x - 5, grid.y + 5)), None);
assert_eq!(heatmap.cell_at(Point::new(grid.x + 5, grid.y - 5)), None);
assert_eq!(heatmap.cell_at(Point::new(grid.x + grid.width as i32 + 5, grid.y + 5)), None);
}
#[test]
fn heatmap_hover_emits_and_leave_clears() {
use crate::compat::Arc;
use core::sync::atomic::{AtomicI64, Ordering};
let mut heatmap = sample();
let seen = Arc::new(AtomicI64::new(-1));
let sink = seen.clone();
heatmap.cell_hovered.connect(move |payload| {
let (row, column) = *payload;
sink.store((row * 16 + column) as i64, Ordering::SeqCst);
});
let grid = heatmap.grid_rect();
heatmap.handle_event(&Event::MouseMove { pos: Point::new(grid.x + 3, grid.y + 3) });
assert_eq!(seen.load(Ordering::SeqCst), 0, "the first cell is (0, 0)");
heatmap.handle_event(&Event::MouseMove { pos: Point::new(grid.x + 4, grid.y + 4) });
assert_eq!(seen.load(Ordering::SeqCst), 0);
heatmap.handle_event(&Event::MouseLeave { pos: Point::new(0, 0) });
assert_eq!(heatmap.hovered_cell, None, "leaving clears the highlight");
}
#[test]
fn heatmap_click_reports_the_cell() {
use crate::compat::Arc;
use core::sync::atomic::{AtomicI64, Ordering};
let mut heatmap = sample();
let seen = Arc::new(AtomicI64::new(-1));
let sink = seen.clone();
heatmap.cell_clicked.connect(move |payload| {
let (row, column) = *payload;
sink.store((row * 16 + column) as i64, Ordering::SeqCst);
});
let grid = heatmap.grid_rect();
let x = grid.x + grid.width as i32 - 3;
let y = grid.y + grid.height as i32 - 3;
heatmap.handle_event(&Event::MousePress { pos: Point::new(x, y), button: 1 });
assert_eq!(seen.load(Ordering::SeqCst), 2 * 16 + 3);
seen.store(-1, Ordering::SeqCst);
heatmap.handle_event(&Event::MousePress {
pos: Point::new(grid.x - 20, grid.y - 20),
button: 1,
});
assert_eq!(seen.load(Ordering::SeqCst), -1, "a click outside the grid emits nothing");
}
#[test]
fn heatmap_disabled_ignores_pointer_events() {
use crate::compat::Arc;
use core::sync::atomic::{AtomicBool, Ordering};
let mut heatmap = sample();
heatmap.base_mut().set_enabled(false);
let fired = Arc::new(AtomicBool::new(false));
let flag = fired.clone();
heatmap.cell_clicked.connect(move |_| flag.store(true, Ordering::SeqCst));
let grid = heatmap.grid_rect();
heatmap.handle_event(&Event::MousePress {
pos: Point::new(grid.x + 3, grid.y + 3),
button: 1,
});
assert!(!fired.load(Ordering::SeqCst), "a disabled heat map must not emit");
}
#[test]
fn heatmap_tiny_geometry_does_not_panic() {
let mut heatmap = Heatmap::new(Rect::new(0, 0, 4, 4));
heatmap.set_data(
vec!["R1".to_string()],
vec!["C1".to_string()],
vec![vec![HeatmapCell::new(1.0)]],
);
let grid = heatmap.grid_rect();
assert_eq!(grid.width, 0, "a control narrower than its margins has no grid");
assert_eq!(heatmap.cell_at(Point::new(1, 1)), None);
let _ = ink(&mut heatmap, Color::rgb(255, 0, 255));
let mut zero = Heatmap::new(Rect::new(0, 0, 0, 0));
zero.set_data(
vec!["R1".to_string()],
vec!["C1".to_string()],
vec![vec![HeatmapCell::new(1.0)]],
);
let _ = ink(&mut zero, Color::rgb(255, 0, 255));
}
#[test]
fn heatmap_set_data_keeps_the_grid_square_with_its_labels() {
let mut heatmap = Heatmap::new(Rect::new(0, 0, 240, 140));
heatmap.set_data(
vec!["R1".to_string(), "R2".to_string()],
vec!["C1".to_string(), "C2".to_string(), "C3".to_string()],
vec![
vec![HeatmapCell::new(1.0)],
vec![HeatmapCell::new(2.0), HeatmapCell::new(3.0), HeatmapCell::new(4.0)],
vec![HeatmapCell::new(9.0)],
],
);
assert_eq!(heatmap.row_count(), 2);
assert_eq!(heatmap.column_count(), 3);
assert_eq!(heatmap.cell(0, 0).unwrap().value, Some(1.0));
assert_eq!(heatmap.cell(0, 1).unwrap().value, None, "a short row is padded with empty");
assert_eq!(heatmap.cell(0, 2).unwrap().value, None);
assert_eq!(heatmap.cell(1, 2).unwrap().value, Some(4.0));
assert_eq!(heatmap.values.len(), 2, "the extra third row is dropped");
}
#[test]
fn heatmap_missing_rows_stay_empty() {
let mut heatmap = Heatmap::new(Rect::new(0, 0, 240, 140));
heatmap.set_data(
vec!["R1".to_string(), "R2".to_string(), "R3".to_string()],
vec!["C1".to_string()],
vec![vec![HeatmapCell::new(5.0)]],
);
assert_eq!(heatmap.row_count(), 3);
assert_eq!(heatmap.cell(0, 0).unwrap().value, Some(5.0));
assert_eq!(heatmap.cell(1, 0).unwrap().value, None);
assert_eq!(heatmap.cell(2, 0).unwrap().value, None);
}
#[test]
fn heatmap_published_properties_round_trip() {
use crate::widget::capability::WidgetProperties;
let mut heatmap = sample();
assert_eq!(heatmap.get("row_count").unwrap(), CapabilityValue::UInt(3));
assert_eq!(heatmap.get("column_count").unwrap(), CapabilityValue::UInt(4));
assert_eq!(heatmap.get("show_labels").unwrap(), CapabilityValue::Bool(true));
assert_eq!(heatmap.get("show_legend").unwrap(), CapabilityValue::Bool(true));
assert_eq!(heatmap.get("scale_minimum").unwrap(), CapabilityValue::Null);
assert_eq!(heatmap.get("resolved_minimum").unwrap(), CapabilityValue::Float(1.0));
assert_eq!(heatmap.get("resolved_maximum").unwrap(), CapabilityValue::Float(12.0));
assert_eq!(heatmap.set("show_labels", CapabilityValue::Bool(false)), Ok(()));
assert_eq!(heatmap.get("show_labels").unwrap(), CapabilityValue::Bool(false));
assert_eq!(heatmap.set("scale_minimum", CapabilityValue::Float(0.5)), Ok(()));
assert_eq!(heatmap.get("scale_minimum").unwrap(), CapabilityValue::Float(0.5));
assert_eq!(heatmap.set("scale_minimum", CapabilityValue::Null), Ok(()));
assert_eq!(heatmap.get("scale_minimum").unwrap(), CapabilityValue::Null);
assert_eq!(heatmap.set("scale_maximum", CapabilityValue::Int(20)), Ok(()));
assert_eq!(heatmap.get("scale_maximum").unwrap(), CapabilityValue::Float(20.0));
for read_only in ["row_count", "column_count", "resolved_minimum", "resolved_maximum"] {
assert_eq!(
heatmap.set(read_only, CapabilityValue::UInt(1)),
Err(CapabilityAccessError::ReadOnlyProperty),
"{read_only} is derived and must refuse a write"
);
}
assert_eq!(
heatmap.set("scale_minimum", CapabilityValue::String("abc".to_string())),
Err(CapabilityAccessError::TypeMismatch)
);
}
#[test]
fn heatmap_every_published_name_is_readable() {
use crate::widget::capability::WidgetProperties;
let heatmap = sample();
for name in heatmap.property_names() {
assert!(heatmap.get(name).is_ok(), "`{name}` is published but cannot be read");
}
}
#[test]
fn heatmap_commands_are_recognised() {
use crate::widget::capability::WidgetProperties;
let mut heatmap = sample();
assert_eq!(heatmap.command("clear"), Ok(()));
assert_eq!(heatmap.cell(0, 0).unwrap().value, None, "clear empties every cell");
assert_eq!(heatmap.scale_range(), None, "an emptied grid has no range");
assert_eq!(heatmap.command("set_data"), Err(CapabilityAccessError::OutOfRange));
assert_eq!(heatmap.command("set_cell"), Err(CapabilityAccessError::OutOfRange));
assert_eq!(
heatmap.command("definitely_not_a_command"),
Err(CapabilityAccessError::UnknownCommand)
);
}
#[test]
fn heatmap_size_hint_follows_the_grid_shape() {
let empty = Heatmap::new(Rect::new(0, 0, 100, 100));
assert_eq!(empty.size_hint(), empty_default_hint());
let filled = sample();
assert_eq!(
filled.size_hint().width,
4 * MIN_CELL_SIZE + LABEL_GUTTER as u32,
"4 columns must be sized as 4 cells plus the label gutter"
);
assert_eq!(
filled.size_hint().height,
3 * MIN_CELL_SIZE + LABEL_GUTTER as u32 + LEGEND_HEIGHT as u32,
"3 rows must be sized as 3 cells plus the gutter and the legend strip"
);
let mut single = Heatmap::new(Rect::new(0, 0, 100, 100));
single.set_data(
vec!["R1".to_string()],
vec!["C1".to_string()],
vec![vec![HeatmapCell::new(1.0)]],
);
assert!(single.size_hint().width < empty.size_hint().width);
}
fn empty_default_hint() -> Size {
Size::new(
DEFAULT_COLUMNS as u32 * MIN_CELL_SIZE + LABEL_GUTTER as u32,
DEFAULT_ROWS as u32 * MIN_CELL_SIZE + LABEL_GUTTER as u32 + LEGEND_HEIGHT as u32,
)
}
#[test]
fn heatmap_geometry_and_kind_delegate_to_the_base() {
let heatmap = Heatmap::new(Rect::new(5, 6, 70, 80));
assert_eq!(heatmap.geometry(), Rect::new(5, 6, 70, 80));
assert_eq!(heatmap.kind(), WidgetKind::Heatmap);
let other = Heatmap::new(Rect::new(0, 0, 10, 10));
assert_ne!(heatmap.id(), other.id());
}
#[test]
fn heatmap_hidden_legend_returns_its_space_to_the_grid() {
let mut heatmap = sample();
let with_legend = heatmap.grid_rect();
heatmap.set_show_legend(false);
let without = heatmap.grid_rect();
assert!(
without.height > with_legend.height,
"hiding the legend must give its strip back to the grid"
);
}
#[test]
fn heatmap_endpoint_labels_trim_whole_numbers() {
assert_eq!(format_endpoint(10.0), "10");
assert_eq!(format_endpoint(0.0), "0");
assert_eq!(format_endpoint(-3.0), "-3");
assert_eq!(format_endpoint(1.5), "1.50");
}
#[test]
fn heatmap_ramp_is_monotone_and_anchored() {
assert_eq!(interpolate_ramp(0.0), RAMP_LOW);
assert_eq!(interpolate_ramp(0.5), RAMP_MID);
assert_eq!(interpolate_ramp(1.0), RAMP_HIGH);
let mut previous = interpolate_ramp(0.0);
for step in 1..=20 {
let current = interpolate_ramp(step as f32 / 20.0);
let before = previous.r as u32 + previous.g as u32 + previous.b as u32;
let after = current.r as u32 + current.g as u32 + current.b as u32;
assert!(after >= before, "the ramp must not darken as the value rises");
previous = current;
}
assert_eq!(interpolate_ramp(-1.0), RAMP_LOW);
assert_eq!(interpolate_ramp(2.0), RAMP_HIGH);
}
}