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}