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}