1use std::f32::consts::PI;
23
24use egui::{
25 epaint::{PathShape, PathStroke},
26 pos2, Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, Widget,
27 WidgetInfo, WidgetType,
28};
29
30use crate::theme::{placeholder_galley, with_alpha, Palette, Theme, BASELINE_FRAC};
31
32#[derive(Clone, Copy, Debug, PartialEq)]
46pub struct GaugeZones {
47 warn: f32,
48 crit: f32,
49}
50
51impl GaugeZones {
52 pub fn new(warn: f32, crit: f32) -> Self {
56 let warn = warn.clamp(0.0, 1.0);
57 let crit = crit.clamp(0.0, 1.0).max(warn);
58 Self { warn, crit }
59 }
60
61 pub const fn warn(self) -> f32 {
63 self.warn
64 }
65
66 pub const fn crit(self) -> f32 {
68 self.crit
69 }
70
71 pub(crate) fn color(&self, fraction: f32, palette: &Palette) -> Color32 {
72 if fraction >= self.crit {
73 palette.danger
74 } else if fraction >= self.warn {
75 palette.warning
76 } else {
77 palette.success
78 }
79 }
80}
81
82fn clamp_fraction(f: f32) -> f32 {
83 if f.is_nan() {
84 0.0
85 } else {
86 f.clamp(0.0, 1.0)
87 }
88}
89
90fn track_color(palette: &Palette) -> Color32 {
91 if palette.is_dark {
92 palette.bg
93 } else {
94 palette.depth_tint(palette.input_bg, 0.04)
95 }
96}
97
98#[derive(Clone, Debug)]
112#[must_use = "Add with `ui.add(...)`."]
113pub struct RadialGauge {
114 fraction: f32,
115 size: f32,
116 color: Option<Color32>,
117 zones: Option<GaugeZones>,
118 needle: bool,
119 text: Option<String>,
120 unit: Option<String>,
121 show_scale: bool,
122}
123
124impl RadialGauge {
125 pub fn new(fraction: f32) -> Self {
128 Self {
129 fraction: clamp_fraction(fraction),
130 size: 200.0,
131 color: None,
132 zones: None,
133 needle: true,
134 text: None,
135 unit: None,
136 show_scale: true,
137 }
138 }
139
140 #[inline]
144 pub fn size(mut self, size: f32) -> Self {
145 self.size = size.max(80.0);
146 self
147 }
148
149 #[inline]
152 pub fn color(mut self, color: Color32) -> Self {
153 self.color = Some(color);
154 self
155 }
156
157 #[inline]
161 pub fn zones(mut self, zones: GaugeZones) -> Self {
162 self.zones = Some(zones);
163 self
164 }
165
166 #[inline]
168 pub fn needle(mut self, on: bool) -> Self {
169 self.needle = on;
170 self
171 }
172
173 #[inline]
176 pub fn text(mut self, text: impl Into<String>) -> Self {
177 self.text = Some(text.into());
178 self
179 }
180
181 #[inline]
185 pub fn unit(mut self, unit: impl Into<String>) -> Self {
186 self.unit = Some(unit.into());
187 self
188 }
189
190 #[inline]
192 pub fn show_scale(mut self, on: bool) -> Self {
193 self.show_scale = on;
194 self
195 }
196}
197
198impl Widget for RadialGauge {
199 fn ui(self, ui: &mut Ui) -> Response {
200 let theme = Theme::current(ui.ctx());
201 let p = &theme.palette;
202
203 let scale_size = (self.size * 0.052).clamp(9.0, 12.0);
204 let scale_h = if self.show_scale {
205 scale_size + 4.0
206 } else {
207 0.0
208 };
209 let arc_h = self.size * 0.74;
210 let total_h = arc_h + scale_h;
211 let (rect, response) =
212 ui.allocate_exact_size(Vec2::new(self.size, total_h), Sense::hover());
213
214 if ui.is_rect_visible(rect) {
215 let painter = ui.painter();
216 let arc_rect = Rect::from_min_size(rect.min, Vec2::new(self.size, arc_h));
217 let cx = arc_rect.center().x;
218 let cy = arc_rect.top() + self.size * 0.5;
219 let r = self.size * 0.4;
220 let stroke_w = self.size * 0.07;
221
222 let n_segments: usize = 96;
226 let arc_point = |t: f32| -> Pos2 {
227 let a = PI - PI * t;
228 pos2(cx + r * a.cos(), cy - r * a.sin())
229 };
230 let arc_points = |start: f32, end: f32| -> Vec<Pos2> {
231 let span = (end - start).max(0.0);
232 let n = ((n_segments as f32 * span).ceil() as usize).max(2);
233 (0..=n)
234 .map(|i| arc_point(start + span * (i as f32 / n as f32)))
235 .collect()
236 };
237
238 painter.add(PathShape::line(
240 arc_points(0.0, 1.0),
241 PathStroke::new(stroke_w, track_color(p)),
242 ));
243
244 if let Some(z) = &self.zones {
246 painter.add(PathShape::line(
247 arc_points(0.0, z.warn),
248 PathStroke::new(stroke_w, with_alpha(p.success, 56)),
249 ));
250 painter.add(PathShape::line(
251 arc_points(z.warn, z.crit),
252 PathStroke::new(stroke_w, with_alpha(p.warning, 60)),
253 ));
254 painter.add(PathShape::line(
255 arc_points(z.crit, 1.0),
256 PathStroke::new(stroke_w, with_alpha(p.danger, 66)),
257 ));
258 }
259
260 let fill_color = self.color.unwrap_or_else(|| {
262 self.zones
263 .as_ref()
264 .map(|z| z.color(self.fraction, p))
265 .unwrap_or(p.sky)
266 });
267 if self.fraction > 0.0 {
268 painter.add(PathShape::line(
269 arc_points(0.0, self.fraction),
270 PathStroke::new(stroke_w, fill_color),
271 ));
272 }
273
274 if let Some(z) = &self.zones {
276 for &boundary in &[z.warn, z.crit] {
277 let a = PI - PI * boundary;
278 let inner_r = r + stroke_w * 0.5 + 1.0;
279 let outer_r = inner_r + stroke_w * 0.55;
280 let inner = pos2(cx + inner_r * a.cos(), cy - inner_r * a.sin());
281 let outer = pos2(cx + outer_r * a.cos(), cy - outer_r * a.sin());
282 painter.line_segment([inner, outer], Stroke::new(1.0, p.text_muted));
283 }
284 }
285
286 if self.needle {
288 let a = PI - PI * self.fraction;
289 let needle_len = r * 0.9;
290 let half_w = (self.size * 0.013).max(1.5);
291 let perp = a + PI * 0.5;
292 let tip = pos2(cx + needle_len * a.cos(), cy - needle_len * a.sin());
293 let base_l = pos2(cx + half_w * perp.cos(), cy - half_w * perp.sin());
294 let base_r = pos2(cx - half_w * perp.cos(), cy + half_w * perp.sin());
295 painter.add(PathShape::convex_polygon(
296 vec![tip, base_l, base_r],
297 p.text,
298 Stroke::NONE,
299 ));
300
301 let pivot_r = (self.size * 0.03).max(4.0);
302 painter.circle_filled(pos2(cx, cy), pivot_r, p.card);
303 painter.circle_stroke(pos2(cx, cy), pivot_r, Stroke::new(1.5, p.text));
304 painter.circle_filled(pos2(cx, cy), pivot_r * 0.28, p.bg);
305 }
306
307 let primary_size = (self.size * 0.15).clamp(14.0, 36.0);
309 let unit_size = (self.size * 0.085).clamp(12.0, 22.0);
310 let primary = self
311 .text
312 .clone()
313 .unwrap_or_else(|| format!("{}", (self.fraction * 100.0).round() as u32));
314 let unit = self.unit.clone().unwrap_or_else(|| {
315 if self.text.is_none() {
316 "%".into()
317 } else {
318 String::new()
319 }
320 });
321
322 if !primary.is_empty() {
323 let g_num = placeholder_galley(ui, &primary, primary_size, true, f32::INFINITY);
324 let g_unit = (!unit.is_empty())
325 .then(|| placeholder_galley(ui, &unit, unit_size, false, f32::INFINITY));
326 let num_w = g_num.size().x;
327 let num_h = g_num.size().y;
328 let unit_w = g_unit.as_ref().map_or(0.0, |g| g.size().x);
329 let gap = if g_unit.is_some() { 3.0 } else { 0.0 };
330 let total_w = num_w + gap + unit_w;
331
332 let bottom_y = arc_rect.bottom() - 6.0;
333 let num_top = bottom_y - num_h;
334 let start_x = cx - total_w * 0.5;
335 painter.galley(pos2(start_x, num_top), g_num, p.text);
336 if let Some(g) = g_unit {
337 let baseline = num_top + num_h * BASELINE_FRAC;
338 let unit_y = baseline - g.size().y * BASELINE_FRAC;
339 painter.galley(pos2(start_x + num_w + gap, unit_y), g, p.text_muted);
340 }
341 }
342
343 if self.show_scale {
345 let label_y = arc_rect.bottom() + 2.0;
346 let g_left = placeholder_galley(ui, "0", scale_size, false, f32::INFINITY);
347 let g_right = placeholder_galley(ui, "100", scale_size, false, f32::INFINITY);
348 painter.galley(
349 pos2(cx - r - g_left.size().x * 0.5, label_y),
350 g_left,
351 p.text_faint,
352 );
353 painter.galley(
354 pos2(cx + r - g_right.size().x * 0.5, label_y),
355 g_right,
356 p.text_faint,
357 );
358 }
359 }
360
361 response.widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, "gauge"));
362 response
363 }
364}
365
366#[derive(Clone, Debug)]
388#[must_use = "Add with `ui.add(...)`."]
389pub struct LinearGauge {
390 fraction: f32,
391 height: f32,
392 desired_width: Option<f32>,
393 color: Option<Color32>,
394 zones: Option<GaugeZones>,
395 threshold_labels: Vec<(f32, String)>,
396 thumb: bool,
397}
398
399impl LinearGauge {
400 pub fn new(fraction: f32) -> Self {
403 Self {
404 fraction: clamp_fraction(fraction),
405 height: 14.0,
406 desired_width: None,
407 color: None,
408 zones: None,
409 threshold_labels: Vec::new(),
410 thumb: true,
411 }
412 }
413
414 #[inline]
416 pub fn height(mut self, height: f32) -> Self {
417 self.height = height.max(6.0);
418 self
419 }
420
421 #[inline]
423 pub fn desired_width(mut self, width: f32) -> Self {
424 self.desired_width = Some(width);
425 self
426 }
427
428 #[inline]
430 pub fn color(mut self, color: Color32) -> Self {
431 self.color = Some(color);
432 self
433 }
434
435 #[inline]
439 pub fn zones(mut self, zones: GaugeZones) -> Self {
440 self.zones = Some(zones);
441 self
442 }
443
444 pub fn threshold_label(mut self, position: f32, label: impl Into<String>) -> Self {
447 self.threshold_labels
448 .push((position.clamp(0.0, 1.0), label.into()));
449 self
450 }
451
452 pub fn show_zone_labels(mut self) -> Self {
458 if let Some(z) = self.zones {
459 self.threshold_labels
460 .push((z.warn, format!("{}", (z.warn * 100.0).round() as u32)));
461 self.threshold_labels
462 .push((z.crit, format!("{}", (z.crit * 100.0).round() as u32)));
463 }
464 self
465 }
466
467 #[inline]
469 pub fn thumb(mut self, on: bool) -> Self {
470 self.thumb = on;
471 self
472 }
473}
474
475impl Widget for LinearGauge {
476 fn ui(self, ui: &mut Ui) -> Response {
477 let theme = Theme::current(ui.ctx());
478 let p = &theme.palette;
479
480 let label_size = 10.0;
481 let label_pad = 6.0;
482 let label_h = if self.threshold_labels.is_empty() {
483 0.0
484 } else {
485 label_size + label_pad
486 };
487 let width = self
488 .desired_width
489 .unwrap_or_else(|| ui.available_width())
490 .max(self.height * 4.0);
491 let total_h = self.height + label_h;
492 let (rect, response) = ui.allocate_exact_size(Vec2::new(width, total_h), Sense::hover());
493
494 if ui.is_rect_visible(rect) {
495 let painter = ui.painter();
496 let bar_rect = Rect::from_min_size(
497 pos2(rect.left(), rect.top() + label_h),
498 Vec2::new(width, self.height),
499 );
500 let radius = CornerRadius::same((self.height * 0.5).round() as u8);
501
502 painter.rect(
504 bar_rect,
505 radius,
506 p.input_bg,
507 Stroke::new(1.0, p.border),
508 StrokeKind::Inside,
509 );
510
511 if let Some(z) = &self.zones {
514 let r = (self.height * 0.5).round() as u8;
515 let band =
516 |start: f32, end: f32, color: Color32, left_round: bool, right_round: bool| {
517 if end <= start {
518 return;
519 }
520 let x0 = bar_rect.left() + bar_rect.width() * start;
521 let x1 = bar_rect.left() + bar_rect.width() * end;
522 let cr = CornerRadius {
523 nw: if left_round { r } else { 0 },
524 sw: if left_round { r } else { 0 },
525 ne: if right_round { r } else { 0 },
526 se: if right_round { r } else { 0 },
527 };
528 let rect = Rect::from_min_max(
529 pos2(x0, bar_rect.top()),
530 pos2(x1, bar_rect.bottom()),
531 );
532 painter.rect_filled(rect.shrink(0.5), cr, color);
533 };
534 band(0.0, z.warn, with_alpha(p.success, 50), true, false);
535 band(z.warn, z.crit, with_alpha(p.warning, 56), false, false);
536 band(z.crit, 1.0, with_alpha(p.danger, 60), false, true);
537 }
538
539 let fill_color = self.color.unwrap_or_else(|| {
541 self.zones
542 .as_ref()
543 .map(|z| z.color(self.fraction, p))
544 .unwrap_or(p.sky)
545 });
546 let fill_w = bar_rect.width() * self.fraction;
547 if fill_w > 0.5 {
548 let fill_rect =
549 Rect::from_min_size(bar_rect.min, Vec2::new(fill_w, bar_rect.height()));
550 painter
551 .with_clip_rect(fill_rect)
552 .rect_filled(bar_rect, radius, fill_color);
553 }
554
555 if self.thumb && self.fraction > 0.0 {
557 let x = bar_rect.left() + fill_w;
558 painter.line_segment(
559 [
560 pos2(x, bar_rect.top() + 1.0),
561 pos2(x, bar_rect.bottom() - 1.0),
562 ],
563 Stroke::new(2.0, p.text),
564 );
565 }
566
567 for (pos, label) in &self.threshold_labels {
569 let x = bar_rect.left() + bar_rect.width() * pos.clamp(0.0, 1.0);
570 let g = placeholder_galley(ui, label, label_size, false, f32::INFINITY);
571 let label_y = rect.top();
572 painter.galley(pos2(x - g.size().x * 0.5, label_y), g, p.text_faint);
573 let tick_top = label_y + label_size + 1.0;
574 let tick_bot = bar_rect.top() - 1.0;
575 if tick_bot > tick_top {
576 painter.line_segment(
577 [pos2(x, tick_top), pos2(x, tick_bot)],
578 Stroke::new(1.0, p.text_faint),
579 );
580 }
581 }
582 }
583
584 response.widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, "meter"));
585 response
586 }
587}