Skip to main content

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}