use core::cell::{Cell, RefCell};
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;
pub trait ColorRamp: Send + Sync {
fn color_at(&self, fraction: f32) -> Color;
}
impl<F> ColorRamp for F
where
F: Fn(f32) -> Color + Send + Sync,
{
fn color_at(&self, fraction: f32) -> Color {
self(fraction)
}
}
#[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)>,
selected_cell: Option<(usize, usize)>,
show_tooltip: bool,
cluster_rows: bool,
cluster_columns: bool,
row_order: Vec<usize>,
column_order: Vec<usize>,
row_position: Vec<usize>,
column_position: Vec<usize>,
legend_text: RefCell<Option<(u64, u64, u32, String, String, u32)>>,
readout_text: ReadoutCache,
hover_readout_external: bool,
ramp: Option<Box<dyn ColorRamp>>,
legend_cache: Option<(u32, Vec<Color>)>,
data_range_cache: Cell<Option<Option<(f64, f64)>>>,
viewport_rows: Option<usize>,
viewport_columns: Option<usize>,
first_row: usize,
first_column: usize,
region_anchor: Option<(usize, usize)>,
region_current: Option<(usize, usize)>,
pan_drag: Option<(Point, usize, usize)>,
pub region_selected: Signal1<(usize, usize, usize, usize)>,
pub cell_double_clicked: Signal1<(usize, usize)>,
}
type ReadoutCache = RefCell<Option<((usize, usize), u64, u32, String, u32, u32)>>;
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(),
selected_cell: None,
show_tooltip: false,
cluster_rows: false,
cluster_columns: false,
row_order: Vec::new(),
column_order: Vec::new(),
row_position: Vec::new(),
column_position: Vec::new(),
legend_text: RefCell::new(None),
readout_text: RefCell::new(None),
hover_readout_external: false,
ramp: None,
legend_cache: None,
data_range_cache: Cell::new(None),
viewport_rows: None,
viewport_columns: None,
first_row: 0,
first_column: 0,
region_anchor: None,
region_current: None,
pan_drag: None,
region_selected: Signal1::new(),
cell_double_clicked: Signal1::new(),
}
}
pub fn viewport_rows(&self) -> usize {
self.viewport_rows.unwrap_or_else(|| self.row_count()).clamp(1, self.row_count().max(1))
}
pub fn viewport_columns(&self) -> usize {
self.viewport_columns
.unwrap_or_else(|| self.column_count())
.clamp(1, self.column_count().max(1))
}
pub fn set_viewport_rows(&mut self, rows: Option<usize>) {
let rows = rows.map(|rows| rows.max(1));
if self.viewport_rows == rows {
return;
}
self.viewport_rows = rows;
self.clamp_pan();
self.base.request_redraw();
}
pub fn set_viewport_columns(&mut self, columns: Option<usize>) {
let columns = columns.map(|columns| columns.max(1));
if self.viewport_columns == columns {
return;
}
self.viewport_columns = columns;
self.clamp_pan();
self.base.request_redraw();
}
pub fn first_row(&self) -> usize {
self.first_row
}
pub fn first_column(&self) -> usize {
self.first_column
}
pub fn set_pan(&mut self, row: usize, column: usize) {
let row = row.min(self.row_count().saturating_sub(self.viewport_rows()));
let column = column.min(self.column_count().saturating_sub(self.viewport_columns()));
if self.first_row == row && self.first_column == column {
return;
}
self.first_row = row;
self.first_column = column;
self.base.request_redraw();
}
pub fn pan_by(&mut self, rows: isize, columns: isize) {
let row = (self.first_row as isize + rows).max(0) as usize;
let column = (self.first_column as isize + columns).max(0) as usize;
self.set_pan(row, column);
}
pub fn zoom_by(&mut self, factor: f32) {
if !factor.is_finite() || factor <= 0.0 {
return;
}
let rows = ((self.viewport_rows() as f32) / factor).round() as usize;
let columns = ((self.viewport_columns() as f32) / factor).round() as usize;
self.set_viewport_rows(Some(rows.max(1)));
self.set_viewport_columns(Some(columns.max(1)));
}
pub fn reset_view(&mut self) {
self.viewport_rows = None;
self.viewport_columns = None;
self.first_row = 0;
self.first_column = 0;
self.base.request_redraw();
}
fn clamp_pan(&mut self) {
let max_row = self.row_count().saturating_sub(self.viewport_rows());
let max_column = self.column_count().saturating_sub(self.viewport_columns());
self.first_row = self.first_row.min(max_row);
self.first_column = self.first_column.min(max_column);
}
pub fn set_color_ramp(&mut self, ramp: Option<Box<dyn ColorRamp>>) {
self.ramp = ramp;
self.legend_cache = None;
self.base.request_redraw();
}
pub fn has_color_ramp(&self) -> bool {
self.ramp.is_some()
}
pub fn set_show_tooltip(&mut self, show: bool) {
if self.show_tooltip == show {
return;
}
self.show_tooltip = show;
self.base.request_redraw();
}
pub fn show_tooltip(&self) -> bool {
self.show_tooltip
}
pub fn set_cluster_rows(&mut self, cluster: bool) {
if self.cluster_rows == cluster {
return;
}
self.cluster_rows = cluster;
self.rebuild_order();
self.base.request_redraw();
}
pub fn cluster_rows(&self) -> bool {
self.cluster_rows
}
pub fn set_cluster_columns(&mut self, cluster: bool) {
if self.cluster_columns == cluster {
return;
}
self.cluster_columns = cluster;
self.rebuild_order();
self.base.request_redraw();
}
pub fn cluster_columns(&self) -> bool {
self.cluster_columns
}
pub fn selected_cell(&self) -> Option<(usize, usize)> {
self.selected_cell
}
pub fn set_selected_cell(&mut self, cell: Option<(usize, usize)>) {
let valid =
cell.filter(|(row, column)| *row < self.row_count() && *column < self.column_count());
if self.selected_cell == valid {
return;
}
self.selected_cell = valid;
self.base.request_redraw();
}
fn rebuild_order(&mut self) {
let rows = self.values.len();
let columns = self.column_labels.len();
let mut row_order: Vec<usize> = (0..rows).collect();
let mut column_order: Vec<usize> = (0..columns).collect();
if self.cluster_rows {
let means: Vec<f64> = row_order
.iter()
.map(|row| self.values.get(*row).map_or(0.0, |cells| row_mean(cells)))
.collect();
row_order.sort_by(|a, b| means[*a].total_cmp(&means[*b]));
}
if self.cluster_columns {
let mut means = vec![0.0f64; columns];
for row in &self.values {
for (column, cell) in row.iter().enumerate() {
if let Some(value) = cell.value {
means[column] += value;
}
}
}
column_order.sort_by(|a, b| means[*a].total_cmp(&means[*b]));
}
self.row_order = row_order;
self.column_order = column_order;
self.row_position = vec![0; self.row_order.len()];
for (position, data) in self.row_order.iter().enumerate() {
if let Some(slot) = self.row_position.get_mut(*data) {
*slot = position;
}
}
self.column_position = vec![0; self.column_order.len()];
for (position, data) in self.column_order.iter().enumerate() {
if let Some(slot) = self.column_position.get_mut(*data) {
*slot = position;
}
}
self.hovered_cell = None;
}
fn display_row_of(&self, row: usize) -> usize {
self.row_position.get(row).copied().unwrap_or(row)
}
fn display_column_of(&self, column: usize) -> usize {
self.column_position.get(column).copied().unwrap_or(column)
}
pub fn hover_readout_external(&self) -> bool {
self.hover_readout_external
}
pub fn set_hover_readout_external(&mut self, external: bool) {
if self.hover_readout_external == external {
return;
}
self.hover_readout_external = external;
self.base.request_redraw();
}
pub fn hover_anchor(
&self,
) -> Option<(Point, crate::compat::Vec<crate::widget::companions::TooltipLine>)> {
use crate::widget::companions::TooltipLine;
let (row, column) = self.hovered_cell?;
let display_row = self.display_row_of(row);
let display_column = self.display_column_of(column);
let cell = self.cell_rect(display_row, display_column);
let grid = self.grid_rect();
let anchor = cell
.map(|cell| Point::new(cell.x + cell.width as i32, cell.y))
.unwrap_or_else(|| Point::new(grid.x, grid.y));
let row_label = self.row_labels.get(row).map(String::as_str).unwrap_or("");
let column_label = self.column_labels.get(column).map(String::as_str).unwrap_or("");
let text = match self.cell(row, column).and_then(|cell| cell.value) {
Some(value) => format!("{row_label} / {column_label}: {}", format_endpoint(value)),
None => format!("{row_label} / {column_label}: \u{2014}"),
};
let lines = crate::compat::Vec::from([TooltipLine::new(text)]);
Some((anchor, lines))
}
fn data_row(&self, position: usize) -> usize {
self.row_order.get(position).copied().unwrap_or(position)
}
fn data_column(&self, position: usize) -> usize {
self.column_order.get(position).copied().unwrap_or(position)
}
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();
self.row_order = (0..self.values.len()).collect();
self.column_order = (0..columns).collect();
self.rebuild_order();
self.legend_cache = None;
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.region_anchor = None;
self.region_current = None;
self.first_row = 0;
self.first_column = 0;
self.clamp_pan();
self.data_range_cache.set(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.data_range_cache.set(None);
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.data_range_cache.set(None);
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) {
if self.show_labels == show {
return;
}
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) {
if self.show_legend == show {
return;
}
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)> {
if let Some(cached) = self.data_range_cache.get() {
return cached;
}
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;
}
}
}
let range = if seen { Some((low, high)) } else { None };
self.data_range_cache.set(Some(range));
range
}
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;
match self.ramp.as_ref() {
Some(ramp) => ramp.color_at(fraction),
None => 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.viewport_rows();
let columns = self.viewport_columns();
if self.row_count() == 0 || self.column_count() == 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;
let display_row = (row + self.first_row).min(self.row_count() - 1);
let display_column = (column + self.first_column).min(self.column_count() - 1);
Some((self.data_row(display_row), self.data_column(display_column)))
}
fn cell_rect(&self, display_row: usize, display_column: usize) -> Option<Rect> {
let grid = self.grid_rect();
let rows = self.viewport_rows();
let columns = self.viewport_columns();
if display_row < self.first_row
|| display_column < self.first_column
|| display_row >= self.first_row + rows
|| display_column >= self.first_column + columns
{
return None;
}
let row = display_row - self.first_row;
let column = display_column - self.first_column;
let cell_width = grid.width as i32 / columns.max(1) as i32;
let cell_height = grid.height as i32 / rows.max(1) as i32;
if cell_width <= 0 || cell_height <= 0 {
return None;
}
let x = grid.x + column as i32 * cell_width;
let y = grid.y + row as i32 * cell_height;
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
};
Some(Rect::new(x, y, width.max(1) as u32, height.max(1) as u32))
}
}
const LABEL_GUTTER: i32 = 56;
const LEGEND_HEIGHT: i32 = RAMP_HEIGHT + RAMP_GAP + LEGEND_LABEL_HEIGHT + LEGEND_BOTTOM_GUARD;
const RAMP_HEIGHT: i32 = 8;
const RAMP_GAP: i32 = 2;
const LEGEND_LABEL_HEIGHT: i32 = 14;
const LEGEND_BOTTOM_GUARD: i32 = 1;
fn pad_row(mut row: Vec<HeatmapCell>, columns: usize) -> Vec<HeatmapCell> {
row.truncate(columns);
while row.len() < columns {
row.push(HeatmapCell::empty());
}
row
}
fn row_mean(cells: &[HeatmapCell]) -> f64 {
let mut total = 0.0;
let mut count = 0usize;
for value in cells.iter().filter_map(|cell| cell.value) {
total += value;
count += 1;
}
if count == 0 {
0.0
} else {
total / count as f64
}
}
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 set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
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!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"cell_clicked" => {
Some(EventSignalRef::mapped("cell_clicked", &self.cell_clicked, |v| {
CapabilityValue::Tuple(alloc::vec![
CapabilityValue::UInt(v.0 as u64),
CapabilityValue::UInt(v.1 as u64),
])
}))
}
"cell_hovered" => {
Some(EventSignalRef::mapped("cell_hovered", &self.cell_hovered, |v| {
CapabilityValue::Tuple(alloc::vec![
CapabilityValue::UInt(v.0 as u64),
CapabilityValue::UInt(v.1 as u64),
])
}))
}
"cell_double_clicked" => Some(EventSignalRef::mapped(
"cell_double_clicked",
&self.cell_double_clicked,
|v| {
CapabilityValue::Tuple(alloc::vec![
CapabilityValue::UInt(v.0 as u64),
CapabilityValue::UInt(v.1 as u64),
])
},
)),
"region_selected" => {
Some(EventSignalRef::mapped("region_selected", &self.region_selected, |v| {
CapabilityValue::Tuple(alloc::vec![
CapabilityValue::UInt(v.0 as u64),
CapabilityValue::UInt(v.1 as u64),
CapabilityValue::UInt(v.2 as u64),
CapabilityValue::UInt(v.3 as u64),
])
}))
}
_ => None,
}
}
}
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())),
"viewport_rows" => Ok(CapabilityValue::UInt(self.viewport_rows() as u64)),
"viewport_columns" => Ok(CapabilityValue::UInt(self.viewport_columns() as u64)),
_ => 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(())
}
"viewport_rows" => {
let rows = match value {
CapabilityValue::UInt(rows) => {
usize::try_from(rows).map_err(|_| CapabilityAccessError::TypeMismatch)?
}
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_viewport_rows((rows > 0).then_some(rows));
Ok(())
}
"viewport_columns" => {
let columns = match value {
CapabilityValue::UInt(columns) => {
usize::try_from(columns).map_err(|_| CapabilityAccessError::TypeMismatch)?
}
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_viewport_columns((columns > 0).then_some(columns));
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",
"viewport_rows",
"viewport_columns",
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.data_range_cache.set(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();
self.refresh_legend_cache();
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.face_with_gradient(
rect,
background,
self.style().background_gradient.as_ref(),
self.style().surface.unwrap_or_default(),
self.style().border_radius.unwrap_or(0),
Color::BLACK,
);
if let Some(border_color) = style.border_color {
crate::widget::draw::stroke_surface_border(
context,
rect,
self.style().border_radius.unwrap_or(0),
border_color,
1,
);
}
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 {
let line = context.text_line(rect, font);
context.draw_text(
Point::new(rect.x + 6, line.y),
"Heatmap",
font,
text_color,
HorizontalAlignment::Left,
);
return;
}
let view_rows = self.viewport_rows();
let view_columns = self.viewport_columns();
let cell_width = grid.width as i32 / view_columns as i32;
let cell_height = grid.height as i32 / view_rows as i32;
if cell_width <= 0 || cell_height <= 0 {
return;
}
for row in 0..view_rows {
let y = grid.y + row as i32 * cell_height;
let display_row = self.first_row + row;
let data_row = self.data_row(display_row);
for column in 0..view_columns {
let x = grid.x + column as i32 * cell_width;
let display_column = self.first_column + column;
let data_column = self.data_column(display_column);
let width = if column + 1 == view_columns {
grid.width as i32 - column as i32 * cell_width
} else {
cell_width
};
let height = if row + 1 == view_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(data_row, data_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((data_row, data_column)) {
context.draw_rect(cell_rect, border);
}
if self.selected_cell == Some((data_row, data_column)) {
context.draw_rect_stroke(cell_rect, text_color, 2);
}
}
}
if let (Some(anchor), Some(current)) = (self.region_anchor, self.region_current) {
let (row_start, row_end) = (anchor.0.min(current.0), anchor.0.max(current.0));
let (column_start, column_end) = (anchor.1.min(current.1), anchor.1.max(current.1));
let top_left = self
.cell_rect(self.display_row_of(row_start), self.display_column_of(column_start));
let bottom_right =
self.cell_rect(self.display_row_of(row_end), self.display_column_of(column_end));
if let (Some(top_left), Some(bottom_right)) = (top_left, bottom_right) {
let band = Rect::new(
top_left.x,
top_left.y,
(bottom_right.x + bottom_right.width as i32 - top_left.x).max(1) as u32,
(bottom_right.y + bottom_right.height as i32 - top_left.y).max(1) as u32,
);
context.draw_rect_stroke(band, text_color, 2);
}
}
if self.show_tooltip && !self.hover_readout_external {
if let Some((row, column)) = self.hovered_cell {
let display_row = self.display_row_of(row);
let display_column = self.display_column_of(column);
let cell_box = self.cell_rect(display_row, display_column);
let view_row = display_row.saturating_sub(self.first_row);
let view_column = display_column.saturating_sub(self.first_column);
let value = self.cell(row, column).and_then(|cell| cell.value);
let generation = value.map(f64::to_bits).unwrap_or(0);
let fresh = matches!(
self.readout_text.borrow().as_ref(),
Some((cell, cached_value, cached_font, _, _, _))
if *cell == (row, column)
&& *cached_value == generation
&& *cached_font == font.size().to_bits()
);
if !fresh {
let row_label = self.row_labels.get(row).map(String::as_str).unwrap_or("");
let column_label =
self.column_labels.get(column).map(String::as_str).unwrap_or("");
let text = match value {
Some(value) => {
format!("{row_label} / {column_label}: {}", format_endpoint(value))
}
None => format!("{row_label} / {column_label}: \u{2014}"),
};
let metrics = context.measure_text(&text, font);
*self.readout_text.borrow_mut() = Some((
(row, column),
generation,
font.size().to_bits(),
text,
metrics.width,
metrics.height.max(1),
));
}
let readout = self.readout_text.borrow();
if let Some((_, _, _, text, measured_width, measured_height)) = readout.as_ref() {
let width = *measured_width as i32 + 8;
let height = *measured_height as i32 + 6;
let mut x = cell_box
.map(|cell_box| cell_box.x + cell_box.width as i32)
.unwrap_or(grid.x + view_column as i32 * cell_width);
let mut y = cell_box
.map(|cell_box| cell_box.y - height - 2)
.unwrap_or(grid.y + view_row as i32 * cell_height - height - 2);
if x + width > rect.x + rect.width as i32 {
x = grid.x + view_column as i32 * cell_width - width;
}
if y < rect.y {
y = grid.y + (view_row as i32 + 1) * cell_height + 2;
}
x = x.max(rect.x);
y = y.max(rect.y).min(rect.y + rect.height as i32 - height);
let box_rect = Rect::new(x, y, width.max(1) as u32, height.max(1) as u32);
context.fill_rect(box_rect, background);
context.draw_rect(box_rect, border);
context.draw_text_line(
box_rect,
text,
font,
text_color,
HorizontalAlignment::Center,
);
}
}
}
if self.show_labels {
self.draw_labels(context, grid, font, text_color);
}
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) {
let view_rows = self.viewport_rows();
let view_columns = self.viewport_columns();
let cell_width = grid.width as i32 / view_columns.max(1) as i32;
let cell_height = grid.height as i32 / view_rows.max(1) as i32;
let font_height = context.measure_text("M", font).height as i32;
for view_row in 0..view_rows {
let display_row = self.first_row + view_row;
let data_row = self.data_row(display_row);
let Some(label) = self.row_labels.get(data_row) else {
continue;
};
let y = grid.y + view_row as i32 * cell_height + (cell_height - font_height) / 2;
context.draw_text_fitted_cow(
Rect::new(grid.x - LABEL_GUTTER, y, LABEL_GUTTER as u32, font_height.max(1) as u32),
label,
font,
text_color,
HorizontalAlignment::Left,
);
}
let full = self.geometry();
for view_column in 0..view_columns {
let display_column = self.first_column + view_column;
let data_column = self.data_column(display_column);
let Some(label) = self.column_labels.get(data_column) else {
continue;
};
let cell_x = grid.x + view_column as i32 * cell_width;
let width = if view_column + 1 == view_columns {
grid.width as i32 - view_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_cow(
Rect::new(cell_x, top, width.max(1) as u32, height),
label,
font,
text_color,
HorizontalAlignment::Center,
);
}
}
fn refresh_legend_cache(&mut self) {
let strip_width = self.grid_rect().width as i32;
if strip_width <= 0 {
return;
}
let steps = strip_width.min(512);
if self.legend_cache.as_ref().is_some_and(|(width, _)| *width == steps as u32) {
return;
}
let samples: Vec<Color> = (0..steps)
.map(|step| {
let fraction = step as f32 / (steps - 1).max(1) as f32;
match self.ramp.as_ref() {
Some(ramp) => ramp.color_at(fraction),
None => interpolate_ramp(fraction),
}
})
.collect();
self.legend_cache = Some((steps as u32, samples));
}
fn draw_legend(
&self,
context: &mut RenderContext,
rect: Rect,
grid: Rect,
font: &Font,
text_color: Color,
) {
let strip_height = RAMP_HEIGHT;
let band_top = rect.y + rect.height as i32 - LEGEND_HEIGHT;
let y = band_top;
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);
let samples_borrowed = self.legend_cache.as_ref();
if let Some((_, samples)) = samples_borrowed {
for (step, color) in samples.iter().enumerate() {
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 }, *color);
}
}
let Some((low, high)) = self.scale_range() else {
return;
};
let key = (low.to_bits(), high.to_bits(), font.size().to_bits());
let stale = !matches!(
self.legend_text.borrow().as_ref(),
Some((a, b, c, _, _, _)) if (*a, *b, *c) == key
);
if stale {
let label_height = context.measure_text("0", font).height;
*self.legend_text.borrow_mut() = Some((
key.0,
key.1,
key.2,
format_endpoint(low),
format_endpoint(high),
label_height,
));
}
let legend_text = self.legend_text.borrow();
let Some((_, _, _, low_text, high_text, label_height)) = legend_text.as_ref() else {
return;
};
let label_top = y + strip_height + RAMP_GAP;
let label_band_height = (*label_height).max(1);
context.draw_text_fitted_cow(
Rect::new(strip_x, label_top, strip_width.max(1) as u32, label_band_height),
low_text,
font,
text_color,
HorizontalAlignment::Left,
);
context.draw_text_fitted_cow(
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, .. } => {
if let Some((origin, first_row, first_column)) = self.pan_drag {
let grid = self.grid_rect();
let cell_width = grid.width as i32 / self.viewport_columns() as i32;
let cell_height = grid.height as i32 / self.viewport_rows() as i32;
let rows = (origin.y - pos.y) / cell_height.max(1);
let columns = (origin.x - pos.x) / cell_width.max(1);
let row = (first_row as i32 + rows).max(0) as usize;
let column = (first_column as i32 + columns).max(0) as usize;
self.set_pan(row, column);
return;
}
if self.region_anchor.is_some() {
if let Some(cell) = self.cell_at(*pos) {
if self.region_current != Some(cell) {
self.region_current = Some(cell);
self.base.request_redraw();
}
}
return;
}
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.set_selected_cell(Some((row, column)));
self.region_anchor = Some((row, column));
self.region_current = Some((row, column));
self.cell_clicked.emit((row, column));
}
}
Event::MousePress { pos, button, .. } if *button == 2 || *button == 3 => {
if self.grid_rect().contains_point(*pos) {
self.pan_drag = Some((*pos, self.first_row, self.first_column));
}
}
Event::MouseRelease { .. } => {
self.pan_drag = None;
if let (Some(anchor), Some(current)) = (self.region_anchor, self.region_current) {
self.region_anchor = None;
self.region_current = None;
self.base.request_redraw();
if anchor != current {
let (row_start, row_end) =
(anchor.0.min(current.0), anchor.0.max(current.0));
let (column_start, column_end) =
(anchor.1.min(current.1), anchor.1.max(current.1));
self.region_selected.emit((row_start, column_start, row_end, column_end));
}
}
}
Event::MouseDoubleClick { pos, button: 1 } => {
if let Some((row, column)) = self.cell_at(*pos) {
self.cell_double_clicked.emit((row, column));
}
}
Event::Wheel { delta, .. } => {
if delta.y < 0 {
self.zoom_by(1.2);
} else if delta.y > 0 {
self.zoom_by(1.0 / 1.2);
}
}
#[cfg(feature = "touch")]
Event::TouchBegin { pos, .. } => {
if let Some((row, column)) = self.cell_at(*pos) {
self.set_selected_cell(Some((row, column)));
self.cell_clicked.emit((row, column));
}
}
Event::MouseLeave { .. } => {
let had_region = self.region_anchor.take().is_some();
self.region_current = None;
self.pan_drag = None;
if self.hovered_cell.take().is_some() || had_region {
self.base.request_redraw();
}
}
Event::KeyPress { key, modifiers } => {
let pressed = crate::shortcut::Key::from_key_code(*key);
let pressed_modifiers = crate::shortcut::Modifiers::from_event_bits(*modifiers);
let extend = pressed_modifiers.contains(crate::shortcut::Modifiers::PRIMARY)
|| pressed_modifiers.contains(crate::shortcut::Modifiers::SHIFT);
let step = match pressed {
Some(crate::shortcut::Key::Left) => Some((0isize, -1isize)),
Some(crate::shortcut::Key::Right) => Some((0, 1)),
Some(crate::shortcut::Key::Up) => Some((-1, 0)),
Some(crate::shortcut::Key::Down) => Some((1, 0)),
_ => None,
};
if let Some((row_delta, column_delta)) = step {
if self.row_count() == 0 || self.column_count() == 0 {
return;
}
let current = self.selected_cell.unwrap_or((0, 0));
let display_row =
self.display_row_of(current.0).saturating_add_signed(row_delta);
let display_column =
self.display_column_of(current.1).saturating_add_signed(column_delta);
let display_row = display_row.min(self.row_count() - 1);
let display_column = display_column.min(self.column_count() - 1);
let next = (self.data_row(display_row), self.data_column(display_column));
if display_row < self.first_row {
self.set_pan(display_row, self.first_column);
} else if display_row >= self.first_row + self.viewport_rows() {
self.set_pan(display_row + 1 - self.viewport_rows(), self.first_column);
}
if display_column < self.first_column {
self.set_pan(self.first_row, display_column);
} else if display_column >= self.first_column + self.viewport_columns() {
self.set_pan(self.first_row, display_column + 1 - self.viewport_columns());
}
if extend {
let anchor = self.region_anchor.unwrap_or(current);
self.region_anchor = Some(anchor);
self.region_current = Some(next);
let row_start = anchor.0.min(next.0);
let row_end = anchor.0.max(next.0);
let column_start = anchor.1.min(next.1);
let column_end = anchor.1.max(next.1);
self.region_selected.emit((row_start, column_start, row_end, column_end));
} else {
self.region_anchor = None;
self.region_current = None;
}
self.set_selected_cell(Some(next));
self.base.request_redraw();
return;
}
if matches!(pressed, Some(crate::shortcut::Key::Enter)) {
if let Some(cell) = self.selected_cell {
self.cell_clicked.emit(cell);
self.base.request_redraw();
}
}
if matches!(pressed, Some(crate::shortcut::Key::Escape)) {
self.region_anchor = None;
self.region_current = None;
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()
.as_chunks::<4>()
.0
.iter()
.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, modifiers: 0 });
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,
modifiers: 0,
});
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,
modifiers: 0,
});
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);
}
#[test]
fn viewport_limits_cells_and_pan_clamps() {
let mut heatmap = sample();
assert_eq!(heatmap.viewport_rows(), 3);
assert_eq!(heatmap.viewport_columns(), 4);
heatmap.set_viewport_rows(Some(2));
heatmap.set_viewport_columns(Some(2));
assert_eq!(heatmap.viewport_rows(), 2);
assert_eq!(heatmap.viewport_columns(), 2);
heatmap.set_pan(1, 2);
assert_eq!((heatmap.first_row(), heatmap.first_column()), (1, 2));
heatmap.set_pan(99, 99);
assert_eq!((heatmap.first_row(), heatmap.first_column()), (1, 2));
heatmap.pan_by(-5, -5);
assert_eq!((heatmap.first_row(), heatmap.first_column()), (0, 0));
heatmap.reset_view();
assert_eq!(heatmap.viewport_rows(), 3);
assert_eq!((heatmap.first_row(), heatmap.first_column()), (0, 0));
}
#[test]
fn zoom_by_changes_the_visible_cell_count() {
let mut heatmap = sample();
heatmap.zoom_by(2.0);
assert_eq!(heatmap.viewport_rows(), 2);
assert_eq!(heatmap.viewport_columns(), 2);
heatmap.zoom_by(0.5);
assert_eq!(heatmap.viewport_rows(), 3);
assert_eq!(heatmap.viewport_columns(), 4);
heatmap.zoom_by(0.01);
assert_eq!(heatmap.viewport_rows(), 3);
assert_eq!(heatmap.viewport_columns(), 4);
heatmap.set("viewport_rows", CapabilityValue::UInt(2)).expect("writable");
assert_eq!(heatmap.get("viewport_rows").unwrap(), CapabilityValue::UInt(2));
heatmap.set("viewport_rows", CapabilityValue::UInt(0)).expect("writable");
assert_eq!(heatmap.viewport_rows(), 3);
}
#[test]
fn a_drag_reports_the_selected_region() {
use crate::compat::{Arc, Mutex};
let mut heatmap = sample();
let regions = Arc::new(Mutex::new(Vec::<(usize, usize, usize, usize)>::new()));
let sink = regions.clone();
heatmap.region_selected.connect(move |region| {
if let Ok(mut guard) = sink.lock() {
guard.push(*region);
}
});
let start = heatmap.cell_rect(0, 0).expect("cell 0,0 is laid out");
let end = heatmap.cell_rect(2, 3).expect("cell 2,3 is laid out");
let (sx, sy) = (start.x + 2, start.y + 2);
let (ex, ey) = (end.x + 2, end.y + 2);
heatmap.handle_event(&Event::mouse_press(sx, sy, 1));
heatmap.handle_event(&Event::mouse_move(ex, ey));
heatmap.handle_event(&Event::mouse_release(ex, ey, 1));
let got = regions.lock().ok().map(|guard| guard.clone()).unwrap_or_default();
assert_eq!(got, vec![(0, 0, 2, 3)]);
}
#[test]
fn a_click_without_movement_reports_no_region() {
use crate::compat::Arc;
use core::sync::atomic::{AtomicBool, Ordering};
let mut heatmap = sample();
let fired = Arc::new(AtomicBool::new(false));
let sink = fired.clone();
heatmap.region_selected.connect(move |_| {
sink.store(true, Ordering::SeqCst);
});
let cell = heatmap.cell_rect(1, 1).expect("cell is laid out");
let (x, y) = (cell.x + 2, cell.y + 2);
heatmap.handle_event(&Event::mouse_press(x, y, 1));
heatmap.handle_event(&Event::mouse_release(x, y, 1));
assert!(!fired.load(Ordering::SeqCst), "a click is not a region drag");
}
#[test]
fn a_double_click_reports_its_cell() {
use crate::compat::{Arc, Mutex};
let mut heatmap = sample();
let clicked = Arc::new(Mutex::new(Vec::<(usize, usize)>::new()));
let sink = clicked.clone();
heatmap.cell_double_clicked.connect(move |cell| {
if let Ok(mut guard) = sink.lock() {
guard.push(*cell);
}
});
let cell = heatmap.cell_rect(2, 1).expect("cell is laid out");
heatmap.handle_event(&Event::mouse_double_click(cell.x + 2, cell.y + 2, 1));
let got = clicked.lock().ok().map(|guard| guard.clone()).unwrap_or_default();
assert_eq!(got, vec![(2, 1)]);
}
}
#[cfg(test)]
mod issue_2_8_tests {
use super::*;
use crate::event::Event;
use std::sync::{Arc, Mutex};
fn grid() -> Heatmap {
let mut grid = Heatmap::new(Rect::new(0, 0, 400, 300));
grid.set_data(
vec!["r0".to_string(), "r1".to_string()],
vec!["c0".to_string(), "c1".to_string()],
vec![
vec![HeatmapCell::new(1.0), HeatmapCell::new(2.0)],
vec![HeatmapCell::new(3.0), HeatmapCell::new(4.0)],
],
);
grid
}
#[test]
fn arrow_keys_move_the_selection_and_enter_reports_the_cell() {
let mut grid = grid();
grid.set_selected_cell(Some((0, 0)));
grid.handle_event(&Event::key_press(39, 0));
assert_eq!(grid.selected_cell(), Some((0, 1)));
grid.handle_event(&Event::key_press(40, 0));
assert_eq!(grid.selected_cell(), Some((1, 1)));
let hits = Arc::new(Mutex::new(Vec::<(usize, usize)>::new()));
let sink = hits.clone();
grid.cell_clicked.connect_ref(move |cell| {
if let Ok(mut guard) = sink.lock() {
guard.push(*cell);
}
});
grid.handle_event(&Event::key_press(13, 0));
assert_eq!(hits.lock().ok().map(|guard| guard.clone()).unwrap_or_default(), vec![(1, 1)]);
}
#[test]
fn the_hover_anchor_names_the_hovered_cell() {
let mut grid = grid();
assert!(grid.hover_anchor().is_none());
grid.hovered_cell = Some((1, 0));
let (_, lines) = grid.hover_anchor().expect("a hovered cell has an anchor");
assert!(lines.iter().any(|line| line.text.contains("r1 / c0: 3")));
grid.set_hover_readout_external(true);
assert!(grid.hover_readout_external());
}
}