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.
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}