Skip to main content

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.
90///
91/// # Family
92///
93/// Use [`LineGauge`] for a compact single-line bar with a trailing label, or
94/// [`Context::progress_bar`] / [`Context::progress`] for an unlabeled bar.
95pub struct Gauge<'a> {
96    ctx: Option<&'a mut Context>,
97    ratio: f64,
98    label: Option<String>,
99    width: Option<u32>,
100    color: Option<Color>,
101}
102
103impl<'a> Gauge<'a> {
104    fn new(ctx: &'a mut Context, ratio: f64) -> Self {
105        Self {
106            ctx: Some(ctx),
107            ratio,
108            label: None,
109            width: None,
110            color: None,
111        }
112    }
113
114    /// Set the centered inline label. Empty string is treated as "no label".
115    ///
116    /// Accepts both `&str` and owned `String` via `impl Into<String>` so
117    /// callers with already-owned strings (e.g. `format!(...)`) don't pay a
118    /// redundant clone.
119    pub fn label(mut self, label: impl Into<String>) -> Self {
120        let label = label.into();
121        if label.is_empty() {
122            self.label = None;
123        } else {
124            self.label = Some(label);
125        }
126        self
127    }
128
129    /// Set the bar width in terminal cells (default: 20).
130    pub fn width(mut self, w: u32) -> Self {
131        self.width = Some(w);
132        self
133    }
134
135    /// Override the auto-tiered color with a fixed color.
136    pub fn color(mut self, c: Color) -> Self {
137        self.color = Some(c);
138        self
139    }
140
141    /// Render now and return the [`GaugeResponse`].
142    pub fn show(mut self) -> GaugeResponse {
143        let Some(ctx) = self.ctx.take() else {
144            // `show` consumes the builder, so safe code cannot reach this
145            // branch. Stay defensive if the internal invariant changes.
146            return GaugeResponse::default();
147        };
148        render_gauge(
149            ctx,
150            self.ratio,
151            self.width.unwrap_or(DEFAULT_GAUGE_WIDTH),
152            self.label.as_deref().unwrap_or(""),
153            self.color,
154        )
155    }
156}
157
158impl Drop for Gauge<'_> {
159    fn drop(&mut self) {
160        if let Some(ctx) = self.ctx.take() {
161            let _ = render_gauge(
162                ctx,
163                self.ratio,
164                self.width.unwrap_or(DEFAULT_GAUGE_WIDTH),
165                self.label.as_deref().unwrap_or(""),
166                self.color,
167            );
168        }
169    }
170}
171
172/// Single-line gauge builder. Auto-renders on `Drop`.
173///
174/// Constructed via [`Context::line_gauge`]. Chainable methods configure the
175/// gauge before it renders. Drop to render without capturing a response, or
176/// call [`Self::show`] to render and obtain a [`GaugeResponse`].
177///
178/// `Drop` is intentional: `ui.line_gauge(0.5).filled('━');` is the idiomatic
179/// form when the response isn't needed.
180///
181/// # Family
182///
183/// Use [`Gauge`] for a block-fill bar with a centered label, or
184/// [`Context::progress_bar`] / [`Context::progress`] for an unlabeled bar.
185pub struct LineGauge<'a> {
186    ctx: Option<&'a mut Context>,
187    ratio: f64,
188    label: Option<String>,
189    width: Option<u32>,
190    filled: char,
191    empty: char,
192}
193
194impl<'a> LineGauge<'a> {
195    fn new(ctx: &'a mut Context, ratio: f64) -> Self {
196        Self {
197            ctx: Some(ctx),
198            ratio,
199            label: None,
200            width: None,
201            filled: '━',
202            empty: '─',
203        }
204    }
205
206    /// Set the trailing label, appended after the bar.
207    ///
208    /// Accepts both `&str` and owned `String` via `impl Into<String>` so
209    /// callers with already-owned strings (e.g. `format!(...)`) don't pay a
210    /// redundant clone.
211    pub fn label(mut self, label: impl Into<String>) -> Self {
212        let label = label.into();
213        if label.is_empty() {
214            self.label = None;
215        } else {
216            self.label = Some(label);
217        }
218        self
219    }
220
221    /// Set the bar width in terminal cells (default: 20).
222    pub fn width(mut self, w: u32) -> Self {
223        self.width = Some(w);
224        self
225    }
226
227    /// Set the filled character (default: `'━'`).
228    pub fn filled(mut self, ch: char) -> Self {
229        self.filled = ch;
230        self
231    }
232
233    /// Set the empty character (default: `'─'`).
234    pub fn empty(mut self, ch: char) -> Self {
235        self.empty = ch;
236        self
237    }
238
239    /// Render now and return the [`GaugeResponse`].
240    pub fn show(mut self) -> GaugeResponse {
241        let Some(ctx) = self.ctx.take() else {
242            // `show` consumes the builder, so safe code cannot reach this
243            // branch. Stay defensive if the internal invariant changes.
244            return GaugeResponse::default();
245        };
246        render_line_gauge(
247            ctx,
248            self.ratio,
249            self.width.unwrap_or(DEFAULT_GAUGE_WIDTH),
250            self.filled,
251            self.empty,
252            self.label.as_deref(),
253        )
254    }
255}
256
257impl Drop for LineGauge<'_> {
258    fn drop(&mut self) {
259        if let Some(ctx) = self.ctx.take() {
260            let _ = render_line_gauge(
261                ctx,
262                self.ratio,
263                self.width.unwrap_or(DEFAULT_GAUGE_WIDTH),
264                self.filled,
265                self.empty,
266                self.label.as_deref(),
267            );
268        }
269    }
270}
271
272/// Internal rendering for a block-fill gauge.
273fn render_gauge(
274    ctx: &mut Context,
275    ratio: f64,
276    width: u32,
277    label: &str,
278    color_override: Option<Color>,
279) -> GaugeResponse {
280    let response = ctx.interaction();
281    let clamped = finite_gauge_ratio(ratio);
282    let width = width.max(1);
283    let bar = compose_block_bar(clamped, width, label);
284    let color = color_override.unwrap_or_else(|| gauge_color_for(ctx, clamped));
285    ctx.styled(bar, Style::new().fg(color));
286    GaugeResponse {
287        response,
288        ratio: clamped,
289    }
290}
291
292/// Internal rendering for a single-line gauge.
293fn render_line_gauge(
294    ctx: &mut Context,
295    ratio: f64,
296    width: u32,
297    filled: char,
298    empty: char,
299    label: Option<&str>,
300) -> GaugeResponse {
301    let response = ctx.interaction();
302    let clamped = finite_gauge_ratio(ratio);
303    let width = width.max(1);
304    let bar = compose_line_bar(clamped, width, filled, empty, label);
305    let color = gauge_color_for(ctx, clamped);
306    ctx.styled(bar, Style::new().fg(color));
307    GaugeResponse {
308        response,
309        ratio: clamped,
310    }
311}
312
313fn finite_gauge_ratio(ratio: f64) -> f64 {
314    if ratio.is_finite() {
315        ratio.clamp(0.0, 1.0)
316    } else {
317        0.0
318    }
319}
320
321/// Pick a color from the theme based on the current ratio.
322///
323/// `success` < 50%, `warning` 50–80%, `error` >= 80%.
324fn gauge_color_for(ctx: &Context, ratio: f64) -> Color {
325    if ratio >= 0.80 {
326        ctx.theme.error
327    } else if ratio >= 0.50 {
328        ctx.theme.warning
329    } else {
330        ctx.theme.success
331    }
332}
333
334/// How a label is positioned relative to the bar cells.
335enum LabelMode<'a> {
336    /// Overlay the label on top of the bar, centered. If the bar is too narrow
337    /// to fit `label_w + 2`, the label is omitted entirely (not truncated).
338    Centered(&'a str),
339    /// Append the label after the bar, separated by a single space. Empty or
340    /// missing labels emit nothing.
341    Trailing(Option<&'a str>),
342}
343
344/// Compute the filled-cell count for `ratio`, clamped to `[0, width]`.
345///
346/// Internal math runs in `f64` (the public ratio type) and only crosses to
347/// `u32` at this boundary — keeps `compose_*_bar` precision-stable.
348fn filled_cells(ratio: f64, width: u32) -> u32 {
349    let count = (ratio * f64::from(width)).round() as u32;
350    count.min(width)
351}
352
353/// Shared bar-composition core for `compose_block_bar` / `compose_line_bar`.
354///
355/// Builds `width` cells (filled or empty) and overlays/appends the label
356/// according to `mode`. Unicode width is honored for centered overlays so
357/// multi-byte labels (e.g. CJK) line up correctly.
358fn compose_bar(
359    ratio: f64,
360    width: u32,
361    fill_ch: char,
362    empty_ch: char,
363    mode: LabelMode<'_>,
364) -> String {
365    let width_usize = width as usize;
366    let filled = filled_cells(ratio, width);
367
368    if let LabelMode::Centered(label) = mode
369        && !label.is_empty()
370    {
371        let label_w = UnicodeWidthStr::width(label);
372        if label_w + 2 <= width_usize {
373            // Build the bar then overlay the centered label.
374            let mut cells: Vec<String> = Vec::with_capacity(width_usize);
375            for i in 0..width {
376                cells.push(if i < filled {
377                    fill_ch.to_string()
378                } else {
379                    empty_ch.to_string()
380                });
381            }
382            let label_start = (width_usize.saturating_sub(label_w)) / 2;
383            crate::chart::write_text_cells(&mut cells, label_start, label);
384            return cells.concat();
385        }
386    }
387
388    // Plain bar (no label, label too wide, or trailing mode).
389    let trailing = match mode {
390        LabelMode::Trailing(Some(lbl)) if !lbl.is_empty() => Some(lbl),
391        _ => None,
392    };
393    let mut out = String::with_capacity(
394        width_usize * fill_ch.len_utf8().max(empty_ch.len_utf8())
395            + trailing.map_or(0, |s| s.len() + 1),
396    );
397    for _ in 0..filled {
398        out.push(fill_ch);
399    }
400    for _ in 0..width.saturating_sub(filled) {
401        out.push(empty_ch);
402    }
403    if let Some(lbl) = trailing {
404        out.push(' ');
405        out.push_str(lbl);
406    }
407    out
408}
409
410/// Build a block-style bar (`█` filled, `░` empty) of `width` cells with an
411/// optional centered `label`. The label is omitted (not truncated) when the
412/// bar is too narrow to fit it.
413fn compose_block_bar(ratio: f64, width: u32, label: &str) -> String {
414    compose_bar(ratio, width, '█', '░', LabelMode::Centered(label))
415}
416
417/// Build a single-line bar with configurable fill/empty chars and optional
418/// label appended after the bar (not centered inside).
419fn compose_line_bar(
420    ratio: f64,
421    width: u32,
422    filled: char,
423    empty: char,
424    label: Option<&str>,
425) -> String {
426    compose_bar(ratio, width, filled, empty, LabelMode::Trailing(label))
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn block_bar_no_label() {
435        let bar = compose_block_bar(0.5, 10, "");
436        assert_eq!(bar, "█████░░░░░");
437    }
438
439    #[test]
440    fn block_bar_with_label() {
441        let bar = compose_block_bar(0.5, 12, "50%");
442        assert!(bar.contains("50%"), "label visible: {bar}");
443        // The label sits on the bar — total cells unchanged.
444        assert_eq!(UnicodeWidthStr::width(bar.as_str()), 12);
445    }
446
447    #[test]
448    fn block_bar_cjk_label_keeps_exact_cell_width() {
449        let bar = compose_block_bar(0.5, 12, "한글");
450        assert!(bar.contains("한글"));
451        assert_eq!(UnicodeWidthStr::width(bar.as_str()), 12);
452    }
453
454    #[test]
455    fn block_bar_omits_label_when_too_narrow() {
456        // "12345" is 5 wide; bar of 6 has only 4 free cells (need label_w + 2).
457        let bar = compose_block_bar(0.5, 6, "12345");
458        assert!(!bar.contains("12345"));
459        assert_eq!(UnicodeWidthStr::width(bar.as_str()), 6);
460    }
461
462    #[test]
463    fn line_bar_default_chars() {
464        let bar = compose_line_bar(0.5, 10, '━', '─', None);
465        assert_eq!(bar, "━━━━━─────");
466    }
467
468    #[test]
469    fn line_bar_appends_label() {
470        let bar = compose_line_bar(1.0, 4, '#', '.', Some("done"));
471        assert_eq!(bar, "#### done");
472    }
473
474    #[test]
475    fn block_bar_f64_precision() {
476        // Ratios that f32 rounds differently from f64 still produce stable
477        // block counts — confirms internal math runs in f64.
478        let bar = compose_block_bar(1.0 / 3.0, 30, "");
479        let filled = bar.chars().filter(|&c| c == '█').count();
480        // (1/3 * 30).round() == 10
481        assert_eq!(filled, 10);
482    }
483
484    #[test]
485    fn non_finite_gauge_ratios_resolve_to_zero_in_response() {
486        let mut backend = crate::TestBackend::new(30, 4);
487        let mut gauge_ratio = 1.0;
488        let mut line_ratio = 1.0;
489        backend.render(|ui| {
490            gauge_ratio = ui.gauge(f64::NAN).show().ratio;
491            line_ratio = ui.line_gauge(f64::INFINITY).show().ratio;
492        });
493        assert_eq!(gauge_ratio, 0.0);
494        assert_eq!(line_ratio, 0.0);
495    }
496}