use egui::{
Align2, Color32, FontId, Pos2, Rect, Response, Sense, Shape, Stroke, Ui, epaint::PathShape,
vec2,
};
use crate::colors::{GRAY, GRAY_DARK, SUCCESS, get_text_color};
use core::fmt;
use std::f32::consts::PI;
use std::ops::RangeInclusive;
pub struct Gauge {
value: f64,
value_range: RangeInclusive<f64>,
size: f32,
angle_range: RangeInclusive<i16>,
stroke_width: f32,
text: Option<String>,
bg_color: Option<Color32>,
fg_color: Color32,
text_color: Option<Color32>,
arrow_length_factor: f32,
arrow_width: f32,
ticks: usize,
tick_size: f32,
pointer_radius: f32,
}
impl Gauge {
pub fn new<V>(value: V) -> Self
where
V: Into<f64>,
{
Self {
value: value.into(),
value_range: 0.0..=100.0,
size: 200.0,
angle_range: 0..=180,
stroke_width: 1.5,
text: None,
bg_color: None,
fg_color: SUCCESS,
text_color: None,
arrow_length_factor: 0.8,
arrow_width: 3.0,
ticks: 9,
tick_size: 3.0,
pointer_radius: 3.0,
}
}
pub fn range(mut self, value_range: RangeInclusive<f64>) -> Self {
self.value_range = value_range;
self
}
pub fn size(mut self, size: f32) -> Self {
self.size = size;
self
}
pub fn angle_range(mut self, angle_range: RangeInclusive<i16>) -> Self {
let start = angle_range.start().clamp(&-360, &360);
let end = angle_range.end().clamp(&-360, &360);
self.angle_range = *start..=*end;
self
}
pub fn text(mut self, text: impl fmt::Display) -> Self {
self.text = Some(text.to_string());
self
}
pub fn bg_color(mut self, color: Color32) -> Self {
self.bg_color = Some(color);
self
}
pub fn fg_color(mut self, color: Color32) -> Self {
self.fg_color = color;
self
}
pub fn text_color(mut self, color: Color32) -> Self {
self.text_color = Some(color);
self
}
pub fn ticks(mut self, n: usize) -> Self {
self.ticks = n;
self
}
pub fn pointer_radius(mut self, size: f32) -> Self {
self.pointer_radius = size.max(1.0);
self
}
fn gauge_width(&self) -> f32 {
if self.ticks > 1 {
self.size - self.text_clearance() * 2.0
} else {
self.size
}
}
pub fn stroke_width(mut self, stroke_width: f32) -> Self {
self.stroke_width = stroke_width;
self
}
fn text_clearance(&self) -> f32 {
self.size / 10.0
}
fn radius(&self) -> f32 {
self.gauge_width() / 2.0
}
#[allow(clippy::cast_possible_truncation)]
fn value_to_angle(&self, v: f64) -> i16 {
let max_angle = *self.angle_range.end();
let min_angle = *self.angle_range.start();
let angle_range = f64::from(max_angle - min_angle);
let min_value = self.value_range.start();
let max_value = self.value_range.end();
let normalized = (v - min_value) / (max_value - min_value);
(f64::from(max_angle) - (normalized * angle_range)) as i16
}
pub fn arrow_length_factor(mut self, factor: f32) -> Self {
self.arrow_length_factor = factor.clamp(0., 1.3);
self
}
fn paint(&mut self, ui: &mut Ui, outer_rect: Rect, value: f64) {
let rect = if self.ticks > 1 {
outer_rect.shrink(self.text_clearance())
} else {
outer_rect
};
let min_angle = *self.angle_range.start();
let max_angle = *self.angle_range.end();
let current_angle = self.value_to_angle(value);
let bg_color = if let Some(c) = self.bg_color {
c
} else {
ui.style().visuals.clone().extreme_bg_color
};
self.paint_arc(ui, rect, min_angle, max_angle, bg_color);
self.paint_arc(ui, rect, current_angle, max_angle, self.fg_color);
if self.arrow_length_factor < 0.1 {
self.paint_point(ui, rect, current_angle);
self.paint_point(ui, rect, max_angle);
}
if self.ticks >= 2 {
self.paint_ticks(ui, rect);
}
if self.arrow_length_factor >= 0.1 {
self.paint_arrow(ui, rect, current_angle);
}
if let Some(ref text) = self.text {
self.paint_text(ui, rect, text);
}
}
fn paint_arc(&self, ui: &mut Ui, rect: Rect, start_angle: i16, end_angle: i16, color: Color32) {
if start_angle >= end_angle {
return;
}
let step = ((end_angle - start_angle) / 30).max(1);
let mut points = Vec::with_capacity(
usize::try_from(end_angle - start_angle).unwrap() / usize::try_from(step).unwrap(),
);
let mut angle = start_angle;
while angle <= end_angle {
points.push(position_from_angle(rect, angle, self.radius()));
angle += step;
}
if angle - step < end_angle {
points.push(position_from_angle(rect, end_angle, self.radius()));
}
if !points.is_empty() {
ui.painter().add(Shape::Path(PathShape {
points,
closed: false,
fill: Color32::TRANSPARENT,
stroke: Stroke::new(self.stroke_width, color).into(),
}));
}
}
fn paint_ticks(&self, ui: &mut Ui, rect: Rect) {
let text_color = self.text_color.unwrap_or_else(|| {
if ui.visuals().dark_mode {
GRAY
} else {
Color32::GRAY
}
});
let value_range = *self.value_range.end() - *self.value_range.start();
#[allow(clippy::cast_precision_loss)]
let step = value_range / (self.ticks - 1) as f64;
let font_size = self.gauge_width() / 15.0;
for i in 0..self.ticks {
if i == self.ticks - 1 && self.angle_range.end() - self.angle_range.start() >= 360 {
continue;
}
#[allow(clippy::cast_precision_loss)]
let tick_value = *self.value_range.start() + step * i as f64;
let angle = self.value_to_angle(tick_value);
if self.ticks >= 2 {
let tick_inner = position_from_angle(rect, angle, self.radius() - self.tick_size);
let tick_outer = position_from_angle(rect, angle, self.radius() + self.tick_size);
ui.painter()
.line_segment([tick_inner, tick_outer], Stroke::new(1.0, text_color));
}
let text_pos =
position_from_angle(rect, angle, self.radius() + self.gauge_width() * 0.1);
ui.painter().text(
text_pos,
Align2::CENTER_CENTER,
format!("{}", tick_value.round()),
FontId::proportional(font_size),
text_color,
);
}
}
fn paint_arrow(&self, ui: &mut Ui, rect: Rect, angle: i16) {
let center = rect.center();
let arrow_color = GRAY_DARK;
let arrow_length = self.radius() * self.arrow_length_factor;
let arrow_end = position_from_angle(rect, angle, arrow_length);
ui.painter().line_segment(
[center, arrow_end],
Stroke::new(self.arrow_width, arrow_color),
);
ui.painter()
.circle(center, self.pointer_radius * 0.8, arrow_color, Stroke::NONE);
}
fn paint_text(&self, ui: &mut Ui, rect: Rect, text: &str) {
let text_color = self.text_color.unwrap_or_else(|| get_text_color(ui));
ui.painter().text(
rect.center(),
Align2::CENTER_CENTER,
text,
FontId::proportional(self.gauge_width() / 9.0),
text_color,
);
}
fn paint_point(&self, ui: &mut Ui, rect: Rect, angle: i16) {
let point = position_from_angle(rect, angle, self.radius() - self.stroke_width / 2.0);
ui.painter().circle(
point,
self.pointer_radius,
self.fg_color,
Stroke::new(1.0, self.fg_color),
);
}
}
impl egui::Widget for Gauge {
fn ui(mut self, ui: &mut Ui) -> Response {
let desired_size = vec2(self.size, self.size);
let (rect, response) = ui.allocate_exact_size(desired_size, Sense::hover());
let value = self
.value
.clamp(*self.value_range.start(), *self.value_range.end());
response.widget_info(|| {
egui::WidgetInfo::slider(true, value, self.text.as_ref().map_or("", |s| s.as_str()))
});
if ui.is_rect_visible(rect) {
self.paint(ui, rect, value);
}
response
}
}
fn position_from_angle(rect: Rect, angle: i16, radius: f32) -> Pos2 {
let center = rect.center();
let angle_rad = f32::from(angle) * PI / 180.0;
center + vec2(angle_rad.cos() * radius, -angle_rad.sin() * radius)
}