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