slt/context/widgets_display/gauge.rs
1// Gauge / line_gauge widgets — block-fill and single-line progress indicators
2// with optional inline labels.
3//
4// Introduced in v0.20.0 (#224). Complements the unlabeled
5// `Context::progress_bar` / `Context::progress` (`textarea_progress.rs`).
6//
7// Callers use a chainable builder pattern
8// (`ui.gauge(0.5).label("CPU").width(48)`). Auto-renders on `Drop`; call
9// `.show()` to get a [`GaugeResponse`] back. Ratios are `f64` to match
10// `animate_value`, chart APIs, and `progress_bar` — no more `f32` outliers.
11
12use super::*;
13
14/// Default width for `gauge` and `line_gauge` when no explicit width is set.
15const DEFAULT_GAUGE_WIDTH: u32 = 20;
16
17impl Context {
18 /// Begin building a block-fill progress bar with optional centered label.
19 ///
20 /// `ratio` is clamped to `0.0..=1.0`. The returned [`Gauge`] auto-renders
21 /// when dropped, so a bare `ui.gauge(0.5);` produces a default-width bar.
22 /// Chain `.label(...)`, `.width(...)`, or `.color(...)` to customize.
23 /// Call `.show()` (instead of dropping) to capture a [`GaugeResponse`].
24 ///
25 /// Color tiers follow theme colors: `success` below 50%, `warning` 50–80%,
26 /// `error` at or above 80%. Override per-call with `.color(...)`.
27 ///
28 /// # Example
29 ///
30 /// ```no_run
31 /// # slt::run(|ui: &mut slt::Context| {
32 /// ui.gauge(0.6).label("60%");
33 /// let r = ui.gauge(0.42).label("CPU").width(48).show();
34 /// if r.hovered { /* attach tooltip */ }
35 /// # });
36 /// ```
37 ///
38 /// # Family
39 ///
40 /// The gauge family covers ratio-based progress indicators:
41 ///
42 /// - [`gauge`](Self::gauge) — block-fill bar with a centered label (this method).
43 /// - [`line_gauge`](Self::line_gauge) — single-line bar with a trailing label
44 /// and configurable fill/empty chars.
45 /// - [`progress_bar`](Self::progress_bar) / [`progress`](Self::progress) —
46 /// unlabeled progress bars.
47 pub fn gauge(&mut self, ratio: f64) -> Gauge<'_> {
48 Gauge::new(self, ratio)
49 }
50
51 /// Begin building a single-line gauge with configurable fill/empty chars.
52 ///
53 /// `ratio` is clamped to `0.0..=1.0`. Chain `.label(...)`, `.width(...)`,
54 /// `.filled(...)`, `.empty(...)` to customize. Auto-renders on `Drop`;
55 /// call `.show()` to capture a [`GaugeResponse`].
56 ///
57 /// # Example
58 ///
59 /// ```no_run
60 /// # slt::run(|ui: &mut slt::Context| {
61 /// ui.line_gauge(0.6).label("60%").width(24);
62 /// ui.line_gauge(0.78).label("Memory").width(48).filled('━');
63 /// # });
64 /// ```
65 ///
66 /// # Family
67 ///
68 /// The gauge family covers ratio-based progress indicators:
69 ///
70 /// - [`line_gauge`](Self::line_gauge) — single-line bar with a trailing
71 /// label (this method).
72 /// - [`gauge`](Self::gauge) — block-fill bar with a centered label.
73 /// - [`progress_bar`](Self::progress_bar) / [`progress`](Self::progress) —
74 /// unlabeled progress bars.
75 pub fn line_gauge(&mut self, ratio: f64) -> LineGauge<'_> {
76 LineGauge::new(self, ratio)
77 }
78}
79
80/// Block-fill gauge builder. Auto-renders on `Drop`.
81///
82/// Constructed via [`Context::gauge`]. Chainable `.label`, `.width`, `.color`
83/// methods configure the gauge before it renders. Drop the value to render
84/// without capturing a response, or call [`Self::show`] to render and obtain
85/// a [`GaugeResponse`].
86///
87/// `Drop` is intentional: `ui.gauge(0.5).label("CPU");` is the idiomatic form
88/// when the response isn't needed, mirroring egui's `ui.add(...)`. Use
89/// [`Self::show`] when you need the response.
90pub struct Gauge<'a> {
91 ctx: Option<&'a mut Context>,
92 ratio: f64,
93 label: Option<String>,
94 width: Option<u32>,
95 color: Option<Color>,
96}
97
98impl<'a> Gauge<'a> {
99 fn new(ctx: &'a mut Context, ratio: f64) -> Self {
100 Self {
101 ctx: Some(ctx),
102 ratio,
103 label: None,
104 width: None,
105 color: None,
106 }
107 }
108
109 /// Set the centered inline label. Empty string is treated as "no label".
110 ///
111 /// Accepts both `&str` and owned `String` via `impl Into<String>` so
112 /// callers with already-owned strings (e.g. `format!(...)`) don't pay a
113 /// redundant clone.
114 pub fn label(mut self, label: impl Into<String>) -> Self {
115 let label = label.into();
116 if label.is_empty() {
117 self.label = None;
118 } else {
119 self.label = Some(label);
120 }
121 self
122 }
123
124 /// Set the bar width in terminal cells (default: 20).
125 pub fn width(mut self, w: u32) -> Self {
126 self.width = Some(w);
127 self
128 }
129
130 /// Override the auto-tiered color with a fixed color.
131 pub fn color(mut self, c: Color) -> Self {
132 self.color = Some(c);
133 self
134 }
135
136 /// Render now and return the [`GaugeResponse`].
137 pub fn show(mut self) -> GaugeResponse {
138 // SAFETY: ctx is Some until Drop runs; show consumes self before Drop.
139 let ctx = self.ctx.take().expect("Gauge::show called twice");
140 render_gauge(
141 ctx,
142 self.ratio,
143 self.width.unwrap_or(DEFAULT_GAUGE_WIDTH),
144 self.label.as_deref().unwrap_or(""),
145 self.color,
146 )
147 }
148}
149
150impl Drop for Gauge<'_> {
151 fn drop(&mut self) {
152 if let Some(ctx) = self.ctx.take() {
153 let _ = render_gauge(
154 ctx,
155 self.ratio,
156 self.width.unwrap_or(DEFAULT_GAUGE_WIDTH),
157 self.label.as_deref().unwrap_or(""),
158 self.color,
159 );
160 }
161 }
162}
163
164/// Single-line gauge builder. Auto-renders on `Drop`.
165///
166/// Constructed via [`Context::line_gauge`]. Chainable methods configure the
167/// gauge before it renders. Drop to render without capturing a response, or
168/// call [`Self::show`] to render and obtain a [`GaugeResponse`].
169///
170/// `Drop` is intentional: `ui.line_gauge(0.5).filled('━');` is the idiomatic
171/// form when the response isn't needed.
172pub struct LineGauge<'a> {
173 ctx: Option<&'a mut Context>,
174 ratio: f64,
175 label: Option<String>,
176 width: Option<u32>,
177 filled: char,
178 empty: char,
179}
180
181impl<'a> LineGauge<'a> {
182 fn new(ctx: &'a mut Context, ratio: f64) -> Self {
183 Self {
184 ctx: Some(ctx),
185 ratio,
186 label: None,
187 width: None,
188 filled: '━',
189 empty: '─',
190 }
191 }
192
193 /// Set the trailing label, appended after the bar.
194 ///
195 /// Accepts both `&str` and owned `String` via `impl Into<String>` so
196 /// callers with already-owned strings (e.g. `format!(...)`) don't pay a
197 /// redundant clone.
198 pub fn label(mut self, label: impl Into<String>) -> Self {
199 let label = label.into();
200 if label.is_empty() {
201 self.label = None;
202 } else {
203 self.label = Some(label);
204 }
205 self
206 }
207
208 /// Set the bar width in terminal cells (default: 20).
209 pub fn width(mut self, w: u32) -> Self {
210 self.width = Some(w);
211 self
212 }
213
214 /// Set the filled character (default: `'━'`).
215 pub fn filled(mut self, ch: char) -> Self {
216 self.filled = ch;
217 self
218 }
219
220 /// Set the empty character (default: `'─'`).
221 pub fn empty(mut self, ch: char) -> Self {
222 self.empty = ch;
223 self
224 }
225
226 /// Render now and return the [`GaugeResponse`].
227 pub fn show(mut self) -> GaugeResponse {
228 let ctx = self.ctx.take().expect("LineGauge::show called twice");
229 render_line_gauge(
230 ctx,
231 self.ratio,
232 self.width.unwrap_or(DEFAULT_GAUGE_WIDTH),
233 self.filled,
234 self.empty,
235 self.label.as_deref(),
236 )
237 }
238}
239
240impl Drop for LineGauge<'_> {
241 fn drop(&mut self) {
242 if let Some(ctx) = self.ctx.take() {
243 let _ = render_line_gauge(
244 ctx,
245 self.ratio,
246 self.width.unwrap_or(DEFAULT_GAUGE_WIDTH),
247 self.filled,
248 self.empty,
249 self.label.as_deref(),
250 );
251 }
252 }
253}
254
255/// Internal rendering for a block-fill gauge.
256fn render_gauge(
257 ctx: &mut Context,
258 ratio: f64,
259 width: u32,
260 label: &str,
261 color_override: Option<Color>,
262) -> GaugeResponse {
263 let response = ctx.interaction();
264 let clamped = ratio.clamp(0.0, 1.0);
265 let width = width.max(1);
266 let bar = compose_block_bar(clamped, width, label);
267 let color = color_override.unwrap_or_else(|| gauge_color_for(ctx, clamped));
268 ctx.styled(bar, Style::new().fg(color));
269 GaugeResponse {
270 response,
271 ratio: clamped,
272 }
273}
274
275/// Internal rendering for a single-line gauge.
276fn render_line_gauge(
277 ctx: &mut Context,
278 ratio: f64,
279 width: u32,
280 filled: char,
281 empty: char,
282 label: Option<&str>,
283) -> GaugeResponse {
284 let response = ctx.interaction();
285 let clamped = ratio.clamp(0.0, 1.0);
286 let width = width.max(1);
287 let bar = compose_line_bar(clamped, width, filled, empty, label);
288 let color = gauge_color_for(ctx, clamped);
289 ctx.styled(bar, Style::new().fg(color));
290 GaugeResponse {
291 response,
292 ratio: clamped,
293 }
294}
295
296/// Pick a color from the theme based on the current ratio.
297///
298/// `success` < 50%, `warning` 50–80%, `error` >= 80%.
299fn gauge_color_for(ctx: &Context, ratio: f64) -> Color {
300 if ratio >= 0.80 {
301 ctx.theme.error
302 } else if ratio >= 0.50 {
303 ctx.theme.warning
304 } else {
305 ctx.theme.success
306 }
307}
308
309/// How a label is positioned relative to the bar cells.
310enum LabelMode<'a> {
311 /// Overlay the label on top of the bar, centered. If the bar is too narrow
312 /// to fit `label_w + 2`, the label is omitted entirely (not truncated).
313 Centered(&'a str),
314 /// Append the label after the bar, separated by a single space. Empty or
315 /// missing labels emit nothing.
316 Trailing(Option<&'a str>),
317}
318
319/// Compute the filled-cell count for `ratio`, clamped to `[0, width]`.
320///
321/// Internal math runs in `f64` (the public ratio type) and only crosses to
322/// `u32` at this boundary — keeps `compose_*_bar` precision-stable.
323fn filled_cells(ratio: f64, width: u32) -> u32 {
324 let count = (ratio * f64::from(width)).round() as u32;
325 count.min(width)
326}
327
328/// Shared bar-composition core for `compose_block_bar` / `compose_line_bar`.
329///
330/// Builds `width` cells (filled or empty) and overlays/appends the label
331/// according to `mode`. Unicode width is honored for centered overlays so
332/// multi-byte labels (e.g. CJK) line up correctly.
333fn compose_bar(
334 ratio: f64,
335 width: u32,
336 fill_ch: char,
337 empty_ch: char,
338 mode: LabelMode<'_>,
339) -> String {
340 let width_usize = width as usize;
341 let filled = filled_cells(ratio, width);
342
343 if let LabelMode::Centered(label) = mode {
344 if !label.is_empty() {
345 let label_w = UnicodeWidthStr::width(label);
346 if label_w + 2 <= width_usize {
347 // Build the bar then overlay the centered label.
348 let mut cells: Vec<char> = Vec::with_capacity(width_usize);
349 for i in 0..width {
350 cells.push(if i < filled { fill_ch } else { empty_ch });
351 }
352 let label_start = (width_usize.saturating_sub(label_w)) / 2;
353 let label_end = label_start + label_w;
354 let mut out = String::with_capacity(width_usize * 4 + label.len());
355 for ch in cells.iter().take(label_start) {
356 out.push(*ch);
357 }
358 out.push_str(label);
359 for ch in cells.iter().take(width_usize).skip(label_end) {
360 out.push(*ch);
361 }
362 return out;
363 }
364 }
365 }
366
367 // Plain bar (no label, label too wide, or trailing mode).
368 let trailing = match mode {
369 LabelMode::Trailing(Some(lbl)) if !lbl.is_empty() => Some(lbl),
370 _ => None,
371 };
372 let mut out = String::with_capacity(
373 width_usize * fill_ch.len_utf8().max(empty_ch.len_utf8())
374 + trailing.map_or(0, |s| s.len() + 1),
375 );
376 for _ in 0..filled {
377 out.push(fill_ch);
378 }
379 for _ in 0..width.saturating_sub(filled) {
380 out.push(empty_ch);
381 }
382 if let Some(lbl) = trailing {
383 out.push(' ');
384 out.push_str(lbl);
385 }
386 out
387}
388
389/// Build a block-style bar (`█` filled, `░` empty) of `width` cells with an
390/// optional centered `label`. The label is omitted (not truncated) when the
391/// bar is too narrow to fit it.
392fn compose_block_bar(ratio: f64, width: u32, label: &str) -> String {
393 compose_bar(ratio, width, '█', '░', LabelMode::Centered(label))
394}
395
396/// Build a single-line bar with configurable fill/empty chars and optional
397/// label appended after the bar (not centered inside).
398fn compose_line_bar(
399 ratio: f64,
400 width: u32,
401 filled: char,
402 empty: char,
403 label: Option<&str>,
404) -> String {
405 compose_bar(ratio, width, filled, empty, LabelMode::Trailing(label))
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn block_bar_no_label() {
414 let bar = compose_block_bar(0.5, 10, "");
415 assert_eq!(bar, "█████░░░░░");
416 }
417
418 #[test]
419 fn block_bar_with_label() {
420 let bar = compose_block_bar(0.5, 12, "50%");
421 assert!(bar.contains("50%"), "label visible: {bar}");
422 // The label sits on the bar — total cells unchanged.
423 assert_eq!(UnicodeWidthStr::width(bar.as_str()), 12);
424 }
425
426 #[test]
427 fn block_bar_omits_label_when_too_narrow() {
428 // "12345" is 5 wide; bar of 6 has only 4 free cells (need label_w + 2).
429 let bar = compose_block_bar(0.5, 6, "12345");
430 assert!(!bar.contains("12345"));
431 assert_eq!(UnicodeWidthStr::width(bar.as_str()), 6);
432 }
433
434 #[test]
435 fn line_bar_default_chars() {
436 let bar = compose_line_bar(0.5, 10, '━', '─', None);
437 assert_eq!(bar, "━━━━━─────");
438 }
439
440 #[test]
441 fn line_bar_appends_label() {
442 let bar = compose_line_bar(1.0, 4, '#', '.', Some("done"));
443 assert_eq!(bar, "#### done");
444 }
445
446 #[test]
447 fn block_bar_f64_precision() {
448 // Ratios that f32 rounds differently from f64 still produce stable
449 // block counts — confirms internal math runs in f64.
450 let bar = compose_block_bar(1.0 / 3.0, 30, "");
451 let filled = bar.chars().filter(|&c| c == '█').count();
452 // (1/3 * 30).round() == 10
453 assert_eq!(filled, 10);
454 }
455}