maud_ui/primitives/meter.rs
1//! Meter component — measurement indicator with zones (low/optimal/high).
2
3use maud::{html, Markup};
4
5/// Meter rendering properties
6#[derive(Debug, Clone)]
7pub struct Props {
8 /// Current value of the meter
9 pub value: f64,
10 /// Minimum value (default 0.0)
11 pub min: f64,
12 /// Maximum value (default 100.0)
13 pub max: f64,
14 /// Threshold below which the zone is "suboptimum" (warning)
15 pub low: Option<f64>,
16 /// Threshold above which the zone is "suboptimum" (warning)
17 pub high: Option<f64>,
18 /// The ideal value; if present, determines which side is "good"
19 pub optimum: Option<f64>,
20 /// Label for the meter (accessibility)
21 pub label: String,
22}
23
24impl Default for Props {
25 fn default() -> Self {
26 Self {
27 value: 0.0,
28 min: 0.0,
29 max: 100.0,
30 low: None,
31 high: None,
32 optimum: None,
33 label: String::new(),
34 }
35 }
36}
37
38/// Determine the zone class based on value, thresholds, and optimum.
39fn zone_class(value: f64, min: f64, max: f64, low: Option<f64>, high: Option<f64>, optimum: Option<f64>) -> &'static str {
40 // If no optimum, always optimum
41 if optimum.is_none() {
42 return "optimum";
43 }
44
45 let opt = optimum.unwrap();
46 let mid = (min + max) / 2.0;
47
48 // Determine which side is ideal based on optimum position
49 if opt >= mid {
50 // Ideal side is "high" (e.g., battery level — higher is better)
51 // low = minimum acceptable, high = good threshold
52 if let Some(l) = low {
53 if value < l {
54 return "danger";
55 }
56 }
57 if let Some(h) = high {
58 if value >= h {
59 return "optimum";
60 }
61 }
62 "suboptimum"
63 } else {
64 // Ideal side is "low" (e.g., disk usage — lower is better)
65 // Zones are: optimum (before midpoint), suboptimum (midpoint to high), danger (above high)
66 if let Some(h) = high {
67 if value > h {
68 return "danger";
69 }
70 }
71 let zone_threshold = if let (Some(l), Some(h)) = (low, high) {
72 (l + h) / 2.0
73 } else {
74 low.unwrap_or(high.unwrap_or(mid))
75 };
76 if value <= zone_threshold {
77 "optimum"
78 } else {
79 "suboptimum"
80 }
81 }
82}
83
84/// Render a single meter with the given properties
85pub fn render(props: Props) -> Markup {
86 let pct = ((props.value - props.min) / (props.max - props.min) * 100.0).clamp(0.0, 100.0);
87 let zone = zone_class(props.value, props.min, props.max, props.low, props.high, props.optimum);
88 let width_style = format!("width: {:.1}%", pct);
89
90 html! {
91 div class="mui-meter" role="meter" aria-valuenow=(props.value) aria-valuemin=(props.min) aria-valuemax=(props.max) aria-label=(props.label) {
92 div.mui-meter__track {
93 div class={
94 "mui-meter__bar "
95 "mui-meter__bar--" (zone)
96 } style=(width_style) {}
97 }
98 }
99 }
100}
101
102/// Showcase battery level and disk usage meters
103pub fn showcase() -> Markup {
104 html! {
105 div.mui-showcase__grid {
106 // Battery level (higher is better)
107 div {
108 p.mui-showcase__caption { "Battery level (higher is better)" }
109 div.mui-showcase__row {
110 // Low (danger)
111 div {
112 (render(Props {
113 value: 15.0,
114 min: 0.0,
115 max: 100.0,
116 low: Some(20.0),
117 high: Some(80.0),
118 optimum: Some(100.0),
119 label: "Battery 15%".into(),
120 }))
121 p.mui-showcase__label { "15%" }
122 }
123 // Medium (warning)
124 div {
125 (render(Props {
126 value: 60.0,
127 min: 0.0,
128 max: 100.0,
129 low: Some(20.0),
130 high: Some(80.0),
131 optimum: Some(100.0),
132 label: "Battery 60%".into(),
133 }))
134 p.mui-showcase__label { "60%" }
135 }
136 // High (success)
137 div {
138 (render(Props {
139 value: 90.0,
140 min: 0.0,
141 max: 100.0,
142 low: Some(20.0),
143 high: Some(80.0),
144 optimum: Some(100.0),
145 label: "Battery 90%".into(),
146 }))
147 p.mui-showcase__label { "90%" }
148 }
149 }
150 }
151
152 // Disk usage (lower is better)
153 div {
154 p.mui-showcase__caption { "Disk usage (lower is better)" }
155 div.mui-showcase__row {
156 // Low (success)
157 div {
158 (render(Props {
159 value: 25.0,
160 min: 0.0,
161 max: 100.0,
162 low: Some(20.0),
163 high: Some(80.0),
164 optimum: Some(0.0),
165 label: "Disk 25%".into(),
166 }))
167 p.mui-showcase__label { "25%" }
168 }
169 // Medium (warning)
170 div {
171 (render(Props {
172 value: 70.0,
173 min: 0.0,
174 max: 100.0,
175 low: Some(20.0),
176 high: Some(80.0),
177 optimum: Some(0.0),
178 label: "Disk 70%".into(),
179 }))
180 p.mui-showcase__label { "70%" }
181 }
182 // High (danger)
183 div {
184 (render(Props {
185 value: 95.0,
186 min: 0.0,
187 max: 100.0,
188 low: Some(20.0),
189 high: Some(80.0),
190 optimum: Some(0.0),
191 label: "Disk 95%".into(),
192 }))
193 p.mui-showcase__label { "95%" }
194 }
195 }
196 }
197 }
198 }
199}