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}