Skip to main content

elegance/
percent_slider.rs

1//! Percentage slider — a 0–100% control whose central UI element is the
2//! percentage value itself.
3//!
4//! [`PercentSlider`] is the percent-flavoured preset over
5//! [`MetricSlider`](crate::MetricSlider). It locks the range to
6//! `0.0..=100.0`, prints a small muted `%` suffix beside the headline, and
7//! defaults the tick row to the quartile labels `0`, `25%`, `50%`, `75%`,
8//! `100%`. Reach for [`MetricSlider`](crate::MetricSlider) when the value
9//! carries its own absolute meaning instead.
10//!
11//! [`PercentSlider`] differs from [`Slider`](crate::Slider) in three ways:
12//!
13//! 1. The value is always a 0–100 percentage (`f32`). Pair it with
14//!    [`PercentSlider::callout_fmt`] when the percentage maps to a meaningful
15//!    absolute quantity (a duration, a file size, a budget share) and the
16//!    absolute value will surface in a callout while the user drags.
17//! 2. The visual hierarchy puts the percentage value front and centre,
18//!    rendered large in the top-right of the widget. An optional small
19//!    label sits in the top-left.
20//! 3. Quartile ticks (`0`, `25%`, `50%`, `75%`, `100%`) sit beneath the
21//!    track so "what fraction of the total am I setting?" reads at a
22//!    glance. Hide them with [`PercentSlider::show_ticks`] for compact
23//!    layouts.
24//!
25//! Three snap modes are available, in order of specificity:
26//! [`step`](PercentSlider::step) snaps to multiples of a fixed size
27//! (`5.0` → 0, 5, 10, …); [`steps`](PercentSlider::steps) snaps to `n`
28//! evenly-spaced positions including both endpoints (`steps(5)` → 0, 25,
29//! 50, 75, 100); [`stops`](PercentSlider::stops) snaps to an explicit,
30//! possibly non-uniform list of positions. When `steps` or `stops` is
31//! set, the tick row renders at exactly those positions and the arrow
32//! keys jump between them.
33
34use egui::{Response, Ui, Widget, WidgetText};
35
36use crate::metric_slider::MetricSlider;
37use crate::slider::SliderHandle;
38use crate::theme::Accent;
39
40/// A 0–100% slider whose central UI element is the percentage value itself.
41///
42/// ```no_run
43/// # use elegance::PercentSlider;
44/// # egui::__run_test_ui(|ui| {
45/// let mut share = 45.0_f32;
46/// ui.add(
47///     PercentSlider::new(&mut share)
48///         .label("Cache window")
49///         .callout_fmt(|p| {
50///             let mins = (p * 60.0 / 100.0).round() as i32;
51///             format!("{mins} min")
52///         }),
53/// );
54/// # });
55/// ```
56#[must_use = "Call `ui.add(...)` to render the widget."]
57pub struct PercentSlider<'a> {
58    inner: MetricSlider<'a>,
59}
60
61impl<'a> std::fmt::Debug for PercentSlider<'a> {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct("PercentSlider")
64            .field("inner", &self.inner)
65            .finish()
66    }
67}
68
69impl<'a> PercentSlider<'a> {
70    /// Create a slider bound to `value`, clamped to `0.0..=100.0`.
71    pub fn new(value: &'a mut f32) -> Self {
72        Self {
73            inner: MetricSlider::new(value, 0.0..=100.0)
74                .suffix("%")
75                .tick_fmt(|v| {
76                    if v == 0.0 {
77                        "0".to_string()
78                    } else {
79                        format!("{}%", v.round() as i32)
80                    }
81                }),
82        }
83    }
84
85    /// Add a small label in the top-left of the header row.
86    ///
87    /// The label also names the slider for screen readers. Without one, name
88    /// the returned response with [`egui::Response::accessible_name`], or with
89    /// [`egui::Response::labelled_by`] when a caption is drawn beside it.
90    #[inline]
91    pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
92        self.inner = self.inner.label(label);
93        self
94    }
95
96    /// Pick the fill colour from one of the theme accents. Default: [`Accent::Sky`].
97    #[inline]
98    pub fn accent(mut self, accent: Accent) -> Self {
99        self.inner = self.inner.accent(accent);
100        self
101    }
102
103    /// Show or hide the quartile tick row beneath the track. Default: `true`.
104    #[inline]
105    pub fn show_ticks(mut self, show: bool) -> Self {
106        self.inner = self.inner.show_ticks(show);
107        self
108    }
109
110    /// Snap the value to multiples of `step` percentage points. Default:
111    /// continuous. Common values: `5.0` for "round to 5%", `25.0` for
112    /// quartile snap.
113    ///
114    /// Mutually exclusive with [`steps`](Self::steps) and
115    /// [`stops`](Self::stops); calling this clears either of them.
116    #[inline]
117    pub fn step(mut self, step: f32) -> Self {
118        self.inner = self.inner.step(step);
119        self
120    }
121
122    /// Snap to `n` evenly-spaced positions, including both endpoints.
123    /// `steps(2)` snaps to `{0, 100}`, `steps(5)` to `{0, 25, 50, 75, 100}`,
124    /// `steps(7)` to seven positions whose spacing the widget computes for
125    /// you (so the awkward `100 / 6` math stays out of caller code).
126    ///
127    /// When set, the tick row renders at exactly those positions and the
128    /// arrow keys jump between adjacent stops. Values below `2` are
129    /// promoted to `2`. Mutually exclusive with [`step`](Self::step) and
130    /// [`stops`](Self::stops); calling this clears either of them.
131    #[inline]
132    pub fn steps(mut self, n: usize) -> Self {
133        self.inner = self.inner.steps(n);
134        self
135    }
136
137    /// Snap to an explicit, possibly non-uniform list of positions in
138    /// `0.0..=100.0`. Out-of-range, `NaN`, and duplicate values are filtered
139    /// out; the result is sorted ascending. If fewer than two valid
140    /// positions remain, falls back to `[0.0, 100.0]`.
141    ///
142    /// When set, the tick row renders at exactly these positions and the
143    /// arrow keys jump between adjacent stops. Mutually exclusive with
144    /// [`step`](Self::step) and [`steps`](Self::steps); calling this clears
145    /// either of them.
146    #[inline]
147    pub fn stops(mut self, positions: impl IntoIterator<Item = f32>) -> Self {
148        self.inner = self.inner.stops(positions);
149        self
150    }
151
152    /// Number of decimal places in the headline value. Default: `0`.
153    #[inline]
154    pub fn decimals(mut self, n: usize) -> Self {
155        self.inner = self.inner.decimals(n);
156        self
157    }
158
159    /// Supply a callback to format the *entire* drag-callout text. The
160    /// callback receives the current percentage in `0.0..=100.0` and returns
161    /// the string to render in the callout above the thumb while the user
162    /// drags. When unset, no callout is shown.
163    ///
164    /// The callback has full control over the text. The widget does not
165    /// prepend the percentage. Common patterns:
166    ///
167    /// ```ignore
168    /// // Just the absolute quantity (the headline already shows the percent):
169    /// .callout_fmt(|p| format!("{} min", (p * 60.0 / 100.0).round() as i32))
170    ///
171    /// // Percent and absolute together:
172    /// .callout_fmt(|p| {
173    ///     let mins = (p * 60.0 / 100.0).round() as i32;
174    ///     format!("{}% \u{00B7} {} min", p.round() as i32, mins)
175    /// })
176    /// ```
177    #[inline]
178    pub fn callout_fmt(mut self, fmt: impl Fn(f32) -> String + 'a) -> Self {
179        self.inner = self.inner.callout_fmt(fmt);
180        self
181    }
182
183    /// Override the slider width. Defaults to `ui.available_width()`.
184    #[inline]
185    pub fn desired_width(mut self, width: f32) -> Self {
186        self.inner = self.inner.desired_width(width);
187        self
188    }
189
190    /// Pick the thumb shape. Default: [`SliderHandle::Circle`]. Switch to
191    /// [`SliderHandle::Line`] for a thin vertical bar instead of the standard
192    /// circular knob.
193    #[inline]
194    pub fn handle(mut self, handle: SliderHandle) -> Self {
195        self.inner = self.inner.handle(handle);
196        self
197    }
198}
199
200impl<'a> Widget for PercentSlider<'a> {
201    fn ui(self, ui: &mut Ui) -> Response {
202        self.inner.ui(ui)
203    }
204}