use crate::core::{deg_to_rad, Color, Font, HorizontalAlignment, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::{RenderCommand, RenderContext};
use crate::widget::capability::coercion::{expect_bool, expect_string, expect_u32};
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};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MeterThreshold {
pub from: u32,
pub to: u32,
pub color: Color,
}
pub struct Meter {
base: BaseWidget,
value: u32,
min: u32,
max: u32,
tick_count: u32,
thresholds: Vec<MeterThreshold>,
show_tick_labels: bool,
unit: String,
}
impl Meter {
pub fn new(rect: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Meter, rect, "Meter"),
value: 0,
min: 0,
max: 100,
tick_count: 5,
thresholds: Vec::new(),
show_tick_labels: false,
unit: String::new(),
}
}
pub fn value(&self) -> u32 {
self.value
}
pub fn set_value(&mut self, v: u32) {
let clamped = v.clamp(self.min, self.max);
if self.value == clamped {
return;
}
self.value = clamped;
self.base.changed.emit();
self.base.request_redraw();
}
pub fn set_range(&mut self, min: u32, max: u32) {
self.min = min.min(max);
self.max = max.max(min);
let clamped = self.value.clamp(self.min, self.max);
if self.value != clamped {
self.value = clamped;
self.base.changed.emit();
}
self.base.request_redraw();
}
pub fn minimum(&self) -> u32 {
self.min
}
pub fn set_minimum(&mut self, minimum: u32) {
self.set_range(minimum, self.max);
}
pub fn maximum(&self) -> u32 {
self.max
}
pub fn set_maximum(&mut self, maximum: u32) {
self.set_range(self.min, maximum);
}
pub fn set_tick_count(&mut self, count: u32) {
self.tick_count = count.max(2);
self.base.request_redraw();
}
pub fn tick_count(&self) -> u32 {
self.tick_count
}
pub fn add_threshold_range(&mut self, from: u32, to: u32, color: Color) {
let (low, high) = (from.min(to), from.max(to));
let from = low.max(self.min);
let to = high.min(self.max);
if from > to {
return;
}
self.thresholds.push(MeterThreshold { from, to, color });
self.base.request_redraw();
}
pub fn clear_thresholds(&mut self) {
self.thresholds.clear();
self.base.request_redraw();
}
pub fn thresholds(&self) -> &[MeterThreshold] {
&self.thresholds
}
pub fn set_show_tick_labels(&mut self, show: bool) {
self.show_tick_labels = show;
self.base.request_redraw();
}
pub fn show_tick_labels(&self) -> bool {
self.show_tick_labels
}
pub fn set_unit(&mut self, unit: &str) {
self.unit = unit.to_string();
self.base.request_redraw();
}
pub fn unit(&self) -> &str {
&self.unit
}
pub fn value_text(&self) -> String {
format!("{}{}", self.value, self.unit)
}
fn normalized_value(&self) -> f32 {
if self.max <= self.min {
return 0.0;
}
(self.value - self.min) as f32 / (self.max - self.min) as f32
}
fn normalized(&self, value: u32) -> f32 {
if self.max <= self.min {
return 0.0;
}
((value.clamp(self.min, self.max) - self.min) as f32) / (self.max - self.min) as f32
}
fn value_at_fraction(&self, fraction: f32) -> u32 {
let span = (self.max - self.min) as f32;
(self.min as f32 + span * fraction).round() as u32
}
}
impl Widget for Meter {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
Size::new(200, 200)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for Meter {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"value" => Ok(CapabilityValue::UInt(self.value() as u64)),
"minimum" => Ok(CapabilityValue::UInt(self.minimum() as u64)),
"maximum" => Ok(CapabilityValue::UInt(self.maximum() as u64)),
"tick_count" => Ok(CapabilityValue::UInt(self.tick_count() as u64)),
"show_tick_labels" => Ok(CapabilityValue::Bool(self.show_tick_labels())),
"unit" => Ok(CapabilityValue::String(self.unit().to_string())),
"value_text" => Ok(CapabilityValue::String(self.value_text())),
"threshold_count" => Ok(CapabilityValue::UInt(self.thresholds().len() as u64)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"value" => {
self.set_value(expect_u32(value)?);
Ok(())
}
"minimum" => {
self.set_minimum(expect_u32(value)?);
Ok(())
}
"maximum" => {
self.set_maximum(expect_u32(value)?);
Ok(())
}
"tick_count" => {
self.set_tick_count(expect_u32(value)?);
Ok(())
}
"show_tick_labels" => {
self.set_show_tick_labels(expect_bool(value)?);
Ok(())
}
"unit" => {
let unit = expect_string(value)?;
self.set_unit(&unit);
Ok(())
}
"value_text" | "threshold_count" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"value",
"minimum",
"maximum",
"tick_count",
"show_tick_labels",
"unit",
"value_text",
"threshold_count",
BASE_PROPERTY_NAMES
]
}
}
impl EventHandler for Meter {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
}
}
impl Draw for Meter {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
let center = Point::new(rect.x + rect.width as i32 / 2, rect.y + rect.height as i32 / 2);
let radius = rect.width.min(rect.height).saturating_sub(8) / 2;
if radius < 10 {
return;
}
let arc_start_deg = 135.0_f32;
let arc_sweep_deg = 270.0_f32;
let offset = -90.0_f32;
let start_angle = deg_to_rad(arc_start_deg + offset);
let end_angle = deg_to_rad(arc_start_deg + arc_sweep_deg + offset);
let value_angle =
deg_to_rad(arc_start_deg + arc_sweep_deg * self.normalized_value() + offset);
let track_color = Color::rgb(230, 230, 230);
let value_arc_color = self.style().background_color.unwrap_or(Color::rgb(0, 120, 215));
let needle_color = self.style().text_color.unwrap_or(Color::rgb(60, 60, 60));
let tick_color = Color::rgb(160, 160, 160);
if (end_angle - start_angle).abs() > 0.001 {
context.execute_command(RenderCommand::DrawArc {
center,
radius,
start_angle,
end_angle,
color: track_color,
filled: false,
});
}
for band in &self.thresholds {
let band_start =
deg_to_rad(arc_start_deg + arc_sweep_deg * self.normalized(band.from) + offset);
let band_end =
deg_to_rad(arc_start_deg + arc_sweep_deg * self.normalized(band.to) + offset);
if (band_end - band_start).abs() < 0.001 {
continue;
}
context.execute_command(RenderCommand::DrawArc {
center,
radius,
start_angle: band_start,
end_angle: band_end,
color: band.color,
filled: false,
});
}
if self.value > self.min && (value_angle - start_angle).abs() > 0.001 {
context.execute_command(RenderCommand::DrawArc {
center,
radius,
start_angle,
end_angle: value_angle,
color: value_arc_color,
filled: false,
});
}
if self.tick_count >= 2 {
let tick_outer = radius;
let tick_inner = radius.saturating_sub(6).max(1);
let tick_step = arc_sweep_deg / (self.tick_count - 1) as f32;
let label_radius = tick_inner.saturating_sub(10).max(1) as f32;
let label_font = Font::simple("Sans", 9.0);
for i in 0..self.tick_count {
let tick_angle_deg = arc_start_deg + tick_step * i as f32;
let tick_rad = deg_to_rad(tick_angle_deg + offset);
let outer_x = center.x + (tick_outer as f32 * tick_rad.cos()) as i32;
let outer_y = center.y + (tick_outer as f32 * tick_rad.sin()) as i32;
let inner_x = center.x + (tick_inner as f32 * tick_rad.cos()) as i32;
let inner_y = center.y + (tick_inner as f32 * tick_rad.sin()) as i32;
context.draw_line_stroke(
Point::new(inner_x, inner_y),
Point::new(outer_x, outer_y),
tick_color,
1,
);
if self.show_tick_labels {
let fraction = i as f32 / (self.tick_count - 1) as f32;
let text = format!("{}{}", self.value_at_fraction(fraction), self.unit);
let metrics = context.measure_text(&text, &label_font);
let lx = center.x + (label_radius * tick_rad.cos()) as i32;
let ly = center.y + (label_radius * tick_rad.sin()) as i32;
context.draw_text(
Point::new(lx - metrics.width as i32 / 2, ly + metrics.ascent as i32 / 2),
&text,
&label_font,
tick_color,
HorizontalAlignment::Left,
);
}
}
}
let needle_length = radius.saturating_sub(8).max(1);
let needle_x = center.x + (needle_length as f32 * value_angle.cos()) as i32;
let needle_y = center.y + (needle_length as f32 * value_angle.sin()) as i32;
context.draw_line_stroke(center, Point::new(needle_x, needle_y), needle_color, 2);
context.fill_circle(center, 4, needle_color);
let text = self.value_text();
let value_font = Font::simple("Sans", 12.0);
let metrics = context.measure_text(&text, &value_font);
if rect.height as i32 >= radius as i32 + 24 {
context.draw_text(
Point::new(
center.x - metrics.width as i32 / 2,
center.y + (radius as i32 / 2) + metrics.ascent as i32,
),
&text,
&value_font,
needle_color,
HorizontalAlignment::Left,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{Color, Rect, Size};
use crate::render::{PaintBackend, SoftwarePaintBackend};
#[test]
fn meter_creation() {
let meter = Meter::new(Rect::new(0, 0, 200, 200));
assert_eq!(meter.value(), 0);
assert_eq!(meter.min, 0);
assert_eq!(meter.max, 100);
assert_eq!(meter.tick_count, 5);
assert_eq!(meter.kind(), WidgetKind::Meter);
}
#[test]
fn meter_set_value() {
let mut meter = Meter::new(Rect::new(0, 0, 200, 200));
meter.set_value(50);
assert_eq!(meter.value(), 50);
meter.set_value(200);
assert_eq!(meter.value(), 100);
meter.set_value(0);
assert_eq!(meter.value(), 0);
}
#[test]
fn meter_set_range() {
let mut meter = Meter::new(Rect::new(0, 0, 200, 200));
meter.set_range(10, 50);
assert_eq!(meter.min, 10);
assert_eq!(meter.max, 50);
meter.set_value(30);
assert_eq!(meter.value(), 30);
meter.set_value(5);
assert_eq!(meter.value(), 10);
meter.set_value(60);
assert_eq!(meter.value(), 50);
}
#[test]
fn meter_draw_no_panic() {
let mut meter = Meter::new(Rect::new(0, 0, 200, 200));
meter.set_value(65);
let mut backend = SoftwarePaintBackend::new(Size::new(200, 200), 1.0);
backend.begin_frame(Color::WHITE);
let mut context = RenderContext::new(&mut backend);
meter.draw(&mut context);
backend.end_frame();
let rgba = backend.frame_rgba();
assert!(!rgba.is_empty());
}
#[test]
fn meter_draw_zero_value_no_panic() {
let mut meter = Meter::new(Rect::new(0, 0, 200, 200));
meter.set_value(0);
let mut backend = SoftwarePaintBackend::new(Size::new(200, 200), 1.0);
backend.begin_frame(Color::WHITE);
let mut context = RenderContext::new(&mut backend);
meter.draw(&mut context);
backend.end_frame();
}
#[test]
fn meter_draw_zero_geometry_no_panic() {
let mut meter = Meter::new(Rect::new(0, 0, 0, 0));
let mut backend = SoftwarePaintBackend::new(Size::new(10, 10), 1.0);
backend.begin_frame(Color::WHITE);
let mut context = RenderContext::new(&mut backend);
meter.draw(&mut context);
backend.end_frame();
}
#[test]
fn meter_set_tick_count() {
let mut meter = Meter::new(Rect::new(0, 0, 200, 200));
assert_eq!(meter.tick_count, 5);
meter.set_tick_count(10);
assert_eq!(meter.tick_count, 10);
meter.set_tick_count(0);
assert_eq!(meter.tick_count, 2);
}
#[test]
fn meter_normalized_value() {
let meter = Meter::new(Rect::new(0, 0, 200, 200));
assert!((meter.normalized_value() - 0.0).abs() < f32::EPSILON);
let mut meter = Meter::new(Rect::new(0, 0, 200, 200));
meter.set_value(50);
assert!((meter.normalized_value() - 0.5).abs() < f32::EPSILON);
meter.set_value(100);
assert!((meter.normalized_value() - 1.0).abs() < f32::EPSILON);
meter.set_range(50, 50);
assert!((meter.normalized_value() - 0.0).abs() < f32::EPSILON);
}
#[test]
fn meter_size_hint() {
let meter = Meter::new(Rect::new(0, 0, 100, 100));
assert_eq!(meter.size_hint(), Size::new(200, 200));
}
#[test]
fn meter_geometry_delegation() {
let mut meter = Meter::new(Rect::new(0, 0, 200, 200));
meter.set_geometry(Rect::new(10, 10, 150, 150));
assert_eq!(meter.geometry(), Rect::new(10, 10, 150, 150));
}
#[test]
fn meter_event_delegation() {
let mut meter = Meter::new(Rect::new(0, 0, 200, 200));
meter.handle_event(&Event::KeyDown((37, 0)));
}
fn render(meter: &mut Meter, size: Size) -> Vec<u8> {
let mut backend = SoftwarePaintBackend::new(size, 1.0);
backend.begin_frame(Color::WHITE);
let mut context = RenderContext::new(&mut backend);
meter.draw(&mut context);
backend.end_frame();
backend.frame_rgba().to_vec()
}
fn count_near(rgba: &[u8], target: (u8, u8, u8)) -> usize {
const TOLERANCE: i32 = 24;
rgba.chunks_exact(4)
.filter(|px| {
let dr = (px[0] as i32 - target.0 as i32).abs();
let dg = (px[1] as i32 - target.1 as i32).abs();
let db = (px[2] as i32 - target.2 as i32).abs();
dr <= TOLERANCE && dg <= TOLERANCE && db <= TOLERANCE && px[3] > 0
})
.count()
}
#[test]
fn meter_threshold_band_paints_its_colour_on_the_arc() {
let size = Size::new(200, 200);
let band = Color::rgb(255, 0, 0);
let mut without = Meter::new(Rect::new(0, 0, 200, 200));
without.set_tick_count(2);
let baseline = render(&mut without, size);
assert_eq!(count_near(&baseline, (255, 0, 0)), 0, "nothing red before the band");
let mut with = Meter::new(Rect::new(0, 0, 200, 200));
with.set_tick_count(2);
with.add_threshold_range(70, 100, band);
let painted = render(&mut with, size);
assert!(
count_near(&painted, (255, 0, 0)) > 0,
"the band's arc segment must be painted in the band's colour"
);
}
#[test]
fn meter_threshold_bounds_are_clamped_to_the_range() {
let mut meter = Meter::new(Rect::new(0, 0, 200, 200));
meter.set_range(0, 100);
meter.add_threshold_range(0, 500, Color::rgb(255, 0, 0));
assert_eq!(meter.thresholds().len(), 1);
assert_eq!(meter.thresholds()[0].from, 0);
assert_eq!(meter.thresholds()[0].to, 100);
meter.add_threshold_range(200, 300, Color::rgb(0, 255, 0));
assert_eq!(meter.thresholds().len(), 1);
meter.add_threshold_range(60, 40, Color::rgb(0, 0, 255));
assert_eq!(meter.thresholds().len(), 2);
assert_eq!(meter.thresholds()[1].from, 40);
assert_eq!(meter.thresholds()[1].to, 60);
}
#[test]
fn meter_clear_thresholds_empties_the_bands() {
let mut meter = Meter::new(Rect::new(0, 0, 200, 200));
meter.add_threshold_range(0, 50, Color::rgb(255, 0, 0));
meter.add_threshold_range(50, 100, Color::rgb(0, 255, 0));
assert_eq!(meter.get("threshold_count").unwrap(), CapabilityValue::UInt(2));
meter.clear_thresholds();
assert!(meter.thresholds().is_empty());
assert_eq!(meter.get("threshold_count").unwrap(), CapabilityValue::UInt(0));
}
#[test]
fn meter_tick_labels_add_text_pixels() {
let size = Size::new(200, 200);
let mut without = Meter::new(Rect::new(0, 0, 200, 200));
without.set_tick_count(5);
without.set_show_tick_labels(false);
let bare = render(&mut without, size);
let mut with = Meter::new(Rect::new(0, 0, 200, 200));
with.set_tick_count(5);
with.set_show_tick_labels(true);
let labelled = render(&mut with, size);
let bare_ticks = count_near(&bare, (160, 160, 160));
let labelled_ticks = count_near(&labelled, (160, 160, 160));
assert!(
labelled_ticks > bare_ticks,
"tick labels must paint text: {bare_ticks} -> {labelled_ticks}"
);
}
#[test]
fn meter_tick_labels_track_the_range() {
let mut meter = Meter::new(Rect::new(0, 0, 200, 200));
meter.set_range(0, 100);
assert_eq!(meter.value_at_fraction(0.0), 0);
assert_eq!(meter.value_at_fraction(0.5), 50);
assert_eq!(meter.value_at_fraction(1.0), 100);
meter.set_range(50, 150);
assert_eq!(meter.value_at_fraction(0.0), 50);
assert_eq!(meter.value_at_fraction(1.0), 150);
}
#[test]
fn meter_value_text_appends_the_unit() {
let mut meter = Meter::new(Rect::new(0, 0, 200, 200));
assert_eq!(meter.value_text(), "0", "no unit means no suffix");
meter.set_value(72);
meter.set_unit("°C");
assert_eq!(meter.value_text(), "72°C");
assert_eq!(meter.unit(), "°C");
meter.set_unit("");
assert_eq!(meter.value_text(), "72");
}
#[test]
fn meter_new_properties_round_trip() {
let mut meter = Meter::new(Rect::new(0, 0, 200, 200));
meter.set("tick_count", CapabilityValue::UInt(9)).unwrap();
assert_eq!(meter.tick_count(), 9);
assert_eq!(meter.get("tick_count").unwrap(), CapabilityValue::UInt(9));
meter.set("show_tick_labels", CapabilityValue::Bool(true)).unwrap();
assert!(meter.show_tick_labels());
assert_eq!(meter.get("show_tick_labels").unwrap(), CapabilityValue::Bool(true));
meter.set("unit", CapabilityValue::String("kPa".to_string())).unwrap();
assert_eq!(meter.unit(), "kPa");
assert_eq!(meter.get("unit").unwrap(), CapabilityValue::String("kPa".to_string()));
assert!(meter.set("tick_count", CapabilityValue::Bool(true)).is_err());
assert!(meter.set("unit", CapabilityValue::UInt(1)).is_err());
}
#[test]
fn meter_derived_properties_are_read_only() {
let mut meter = Meter::new(Rect::new(0, 0, 200, 200));
meter.set_value(42);
meter.add_threshold_range(0, 50, Color::rgb(255, 0, 0));
assert_eq!(meter.get("value_text").unwrap(), CapabilityValue::String("42".to_string()));
assert_eq!(meter.get("threshold_count").unwrap(), CapabilityValue::UInt(1));
assert!(meter.set("value_text", CapabilityValue::String("x".into())).is_err());
assert!(meter.set("threshold_count", CapabilityValue::UInt(3)).is_err());
}
#[test]
fn meter_tick_count_floor_is_two() {
let mut meter = Meter::new(Rect::new(0, 0, 200, 200));
meter.set_tick_count(0);
assert_eq!(meter.tick_count(), 2);
}
#[test]
fn meter_draws_with_thresholds_and_labels_without_panicking() {
let mut meter = Meter::new(Rect::new(0, 0, 200, 200));
meter.set_value(72);
meter.set_unit("°C");
meter.set_show_tick_labels(true);
meter.add_threshold_range(0, 40, Color::rgb(15, 157, 88));
meter.add_threshold_range(40, 70, Color::rgb(244, 180, 0));
meter.add_threshold_range(70, 100, Color::rgb(219, 68, 55));
let rgba = render(&mut meter, Size::new(200, 200));
assert!(!rgba.is_empty());
assert!(
count_near(&rgba, (219, 68, 55)) > 0,
"a band ahead of the needle is not covered by the value arc"
);
assert_eq!(
count_near(&rgba, (15, 157, 88)),
0,
"a band behind the needle is covered by the value arc"
);
assert!(count_near(&rgba, (60, 60, 60)) > 0);
}
#[test]
fn meter_tiny_geometry_does_not_panic_with_labels() {
let mut meter = Meter::new(Rect::new(0, 0, 12, 12));
meter.set_show_tick_labels(true);
meter.add_threshold_range(0, 100, Color::rgb(255, 0, 0));
let rgba = render(&mut meter, Size::new(12, 12));
assert!(!rgba.is_empty());
}
}