Skip to main content

nice_plug_egui/widgets/
param_slider.rs

1use std::sync::{Arc, LazyLock};
2
3use egui::emath::GuiRounding;
4use egui::{
5    self, Key, Response, Sense, Stroke, TextEdit, TextStyle, Ui, Vec2, Widget, WidgetText, emath,
6    vec2,
7};
8use nice_plug_core::context::gui::ParamSetter;
9use nice_plug_core::params::Param;
10use parking_lot::Mutex;
11
12use super::util;
13
14/// When shift+dragging a parameter, one pixel dragged corresponds to this much change in the
15/// noramlized parameter.
16const GRANULAR_DRAG_MULTIPLIER: f32 = 0.0015;
17
18static DRAG_NORMALIZED_START_VALUE_MEMORY_ID: LazyLock<egui::Id> =
19    LazyLock::new(|| egui::Id::new((file!(), 0)));
20static DRAG_AMOUNT_MEMORY_ID: LazyLock<egui::Id> = LazyLock::new(|| egui::Id::new((file!(), 1)));
21static IS_DRAGGING_MEMORY_ID: LazyLock<egui::Id> = LazyLock::new(|| egui::Id::new((file!(), 2)));
22static VALUE_ENTRY_MEMORY_ID: LazyLock<egui::Id> = LazyLock::new(|| egui::Id::new((file!(), 3)));
23
24/// A slider widget similar to [`egui::widgets::Slider`] that knows about nice-plug parameters ranges
25/// and can get values for it. The slider supports double click and control click to reset,
26/// shift+drag for granular dragging, text value entry by clicking on the value text.
27///
28/// TODO: Vertical orientation
29/// TODO: Check below for more input methods that should be added
30/// TODO: Decouple the logic from the drawing so we can also do things like nobs without having to
31///       repeat everything
32/// TODO: Add WidgetInfo annotations for accessibility
33#[must_use = "You should put this widget in an ui with `ui.add(widget);`"]
34pub struct ParamSlider<'a, P: Param> {
35    param: &'a P,
36    setter: &'a ParamSetter<'a>,
37
38    draw_value: bool,
39    slider_width: Option<f32>,
40
41    /// Will be set in the `ui()` function so we can request keyboard input focus on Alt+click.
42    keyboard_focus_id: Option<egui::Id>,
43}
44
45impl<'a, P: Param> ParamSlider<'a, P> {
46    /// Create a new slider for a parameter. Use the other methods to modify the slider before
47    /// passing it to [`Ui::add()`].
48    pub fn for_param(param: &'a P, setter: &'a ParamSetter<'a>) -> Self {
49        Self {
50            param,
51            setter,
52
53            draw_value: true,
54            slider_width: None,
55
56            keyboard_focus_id: None,
57        }
58    }
59
60    /// Don't draw the text slider's current value after the slider.
61    pub fn without_value(mut self) -> Self {
62        self.draw_value = false;
63        self
64    }
65
66    /// Set a custom width for the slider.
67    pub fn with_width(mut self, width: f32) -> Self {
68        self.slider_width = Some(width);
69        self
70    }
71
72    fn plain_value(&self) -> P::Plain {
73        self.param.modulated_plain_value()
74    }
75
76    fn normalized_value(&self) -> f32 {
77        self.param.modulated_normalized_value()
78    }
79
80    fn string_value(&self) -> String {
81        self.param.to_string()
82    }
83
84    /// Enable the keyboard entry part of the widget.
85    fn begin_keyboard_entry(&self, ui: &Ui) {
86        ui.memory_mut(|mem| mem.request_focus(self.keyboard_focus_id.unwrap()));
87
88        // Always initialize the field to the current value, that seems nicer than having to
89        // being typing from scratch
90        let value_entry_mutex = ui.memory_mut(|mem| {
91            mem.data
92                .get_temp_mut_or_default::<Arc<Mutex<String>>>(*VALUE_ENTRY_MEMORY_ID)
93                .clone()
94        });
95        *value_entry_mutex.lock() = self.string_value();
96    }
97
98    fn keyboard_entry_active(&self, ui: &Ui) -> bool {
99        ui.memory(|mem| mem.has_focus(self.keyboard_focus_id.unwrap()))
100    }
101
102    fn begin_drag(&self, ui: &Ui) {
103        self.setter.begin_set_parameter(self.param);
104        Self::set_is_dragging_memory(ui, true);
105    }
106
107    fn set_normalized_value(&self, normalized: f32) {
108        // This snaps to the nearest plain value if the parameter is stepped in some way.
109        // TODO: As an optimization, we could add a `const CONTINUOUS: bool` to the parameter to
110        //       avoid this normalized->plain->normalized conversion for parameters that don't need
111        //       it
112        let value = self.param.preview_plain(normalized);
113        if value != self.plain_value() {
114            self.setter.set_parameter(self.param, value);
115        }
116    }
117
118    /// Begin and end drag still need to be called when using this. Returns `false` if the string
119    /// could no tbe parsed.
120    fn set_from_string(&self, string: &str) -> bool {
121        match self.param.string_to_normalized_value(string) {
122            Some(normalized_value) => {
123                self.set_normalized_value(normalized_value);
124                true
125            }
126            None => false,
127        }
128    }
129
130    /// Begin and end drag still need to be called when using this..
131    fn reset_param(&self) {
132        self.setter
133            .set_parameter(self.param, self.param.default_plain_value());
134    }
135
136    fn granular_drag(&self, ui: &Ui, drag_delta: Vec2) {
137        // Remember the intial position when we started with the granular drag. This value gets
138        // reset whenever we have a normal itneraction with the slider.
139        let start_value = if Self::get_drag_amount_memory(ui) == 0.0 {
140            Self::set_drag_normalized_start_value_memory(ui, self.normalized_value());
141            self.normalized_value()
142        } else {
143            Self::get_drag_normalized_start_value_memory(ui)
144        };
145
146        let total_drag_distance = drag_delta.x + Self::get_drag_amount_memory(ui);
147        Self::set_drag_amount_memory(ui, total_drag_distance);
148
149        self.set_normalized_value(
150            (start_value + (total_drag_distance * GRANULAR_DRAG_MULTIPLIER)).clamp(0.0, 1.0),
151        );
152    }
153
154    fn end_drag(&self, ui: &Ui) {
155        self.setter.end_set_parameter(self.param);
156        Self::set_is_dragging_memory(ui, false);
157    }
158
159    fn get_drag_normalized_start_value_memory(ui: &Ui) -> f32 {
160        ui.memory(|mem| {
161            mem.data
162                .get_temp(*DRAG_NORMALIZED_START_VALUE_MEMORY_ID)
163                .unwrap_or(0.5)
164        })
165    }
166
167    fn set_drag_normalized_start_value_memory(ui: &Ui, amount: f32) {
168        ui.memory_mut(|mem| {
169            mem.data
170                .insert_temp(*DRAG_NORMALIZED_START_VALUE_MEMORY_ID, amount)
171        });
172    }
173
174    fn get_drag_amount_memory(ui: &Ui) -> f32 {
175        ui.memory(|mem| mem.data.get_temp(*DRAG_AMOUNT_MEMORY_ID).unwrap_or(0.0))
176    }
177
178    fn set_drag_amount_memory(ui: &Ui, amount: f32) {
179        ui.memory_mut(|mem| mem.data.insert_temp(*DRAG_AMOUNT_MEMORY_ID, amount));
180    }
181
182    fn get_is_dragging_memory(ui: &Ui) -> bool {
183        ui.memory(|mem| mem.data.get_temp(*IS_DRAGGING_MEMORY_ID).unwrap_or(false))
184    }
185
186    fn set_is_dragging_memory(ui: &Ui, is_dragging: bool) {
187        ui.memory_mut(|mem| mem.data.insert_temp(*IS_DRAGGING_MEMORY_ID, is_dragging));
188    }
189
190    fn slider_ui(&mut self, ui: &Ui, response: &mut Response) {
191        // Handle user input
192        // TODO: Optionally (since it can be annoying) add scrolling behind a builder option
193        if response.is_pointer_button_down_on() {
194            let is_dragging = Self::get_is_dragging_memory(ui);
195            if !is_dragging {
196                // When beginning a drag or dragging normally, reset the memory used to keep track of
197                // our granular drag
198                self.begin_drag(ui);
199                Self::set_drag_amount_memory(ui, 0.0);
200            }
201        }
202        if let Some(click_pos) = response.interact_pointer_pos() {
203            if ui.input(|i| i.modifiers.command) {
204                // Like double clicking, Ctrl+Click should reset the parameter
205                self.reset_param();
206                response.mark_changed();
207            // // FIXME: This releases the focus again when you release the mouse button without
208            // //        moving the mouse a bit for some reason
209            // } else if ui.input().modifiers.alt && self.draw_value {
210            //     // Allow typing in the value on an Alt+Click. Right now this is shown as part of the
211            //     // value field, so it only makes sense when we're drawing that.
212            //     self.begin_keyboard_entry(ui);
213            } else if ui.input(|i| i.modifiers.shift) {
214                // And shift dragging should switch to a more granulra input method
215                self.granular_drag(ui, response.drag_delta());
216                response.mark_changed();
217            } else {
218                let proportion =
219                    emath::remap_clamp(click_pos.x, response.rect.x_range(), 0.0..=1.0) as f64;
220                self.set_normalized_value(proportion as f32);
221                response.mark_changed();
222                Self::set_drag_amount_memory(ui, 0.0);
223            }
224        }
225        if response.double_clicked() {
226            self.reset_param();
227            response.mark_changed();
228        }
229        if response.drag_stopped() {
230            self.end_drag(ui);
231        }
232
233        // And finally draw the thing
234        if ui.is_rect_visible(response.rect) {
235            // We'll do a flat widget with background -> filled foreground -> slight border
236            ui.painter()
237                .rect_filled(response.rect, 0.0, ui.visuals().widgets.inactive.bg_fill);
238
239            let filled_proportion = self.normalized_value();
240            if filled_proportion > 0.0 {
241                let mut filled_rect = response.rect;
242                filled_rect.set_width(response.rect.width() * filled_proportion);
243                let filled_bg = if response.hovered() {
244                    util::add_hsv(ui.visuals().selection.bg_fill, 0.0, -0.1, 0.1)
245                } else {
246                    ui.visuals().selection.bg_fill
247                };
248                ui.painter().rect_filled(filled_rect, 0.0, filled_bg);
249            }
250
251            ui.painter().rect_stroke(
252                response.rect,
253                0.0,
254                Stroke::new(1.0f32, ui.visuals().widgets.active.bg_fill),
255                egui::StrokeKind::Middle,
256            );
257        }
258    }
259
260    fn value_ui(&mut self, ui: &mut Ui) {
261        let visuals = ui.visuals().widgets.inactive;
262        let should_draw_frame = ui.visuals().button_frame;
263        let padding = ui.spacing().button_padding;
264
265        // Either show the parameter's label, or show a text entry field if the parameter's label
266        // has been clicked on
267        let keyboard_focus_id = self.keyboard_focus_id.unwrap();
268        if self.keyboard_entry_active(ui) {
269            let value_entry_mutex = ui.memory_mut(|mem| {
270                mem.data
271                    .get_temp_mut_or_default::<Arc<Mutex<String>>>(*VALUE_ENTRY_MEMORY_ID)
272                    .clone()
273            });
274            let mut value_entry = value_entry_mutex.lock();
275
276            ui.add(
277                TextEdit::singleline(&mut *value_entry)
278                    .id(keyboard_focus_id)
279                    .font(TextStyle::Monospace),
280            );
281            if ui.input(|i| i.key_pressed(Key::Escape)) {
282                // Cancel when pressing escape
283                ui.memory_mut(|mem| mem.surrender_focus(keyboard_focus_id));
284            } else if ui.input(|i| i.key_pressed(Key::Enter)) {
285                // And try to set the value by string when pressing enter
286                self.begin_drag(ui);
287                self.set_from_string(&value_entry);
288                self.end_drag(ui);
289
290                ui.memory_mut(|mem| mem.surrender_focus(keyboard_focus_id));
291            }
292        } else {
293            let text = WidgetText::from(self.string_value()).into_galley(
294                ui,
295                None,
296                ui.available_width() - (padding.x * 2.0),
297                TextStyle::Button,
298            );
299
300            let response = ui.allocate_response(text.size() + (padding * 2.0), Sense::click());
301            if response.clicked() {
302                self.begin_keyboard_entry(ui);
303            }
304
305            if ui.is_rect_visible(response.rect) {
306                if should_draw_frame {
307                    let fill = visuals.bg_fill;
308                    let stroke = if response.hovered() {
309                        ui.set_cursor_icon(egui::CursorIcon::Text);
310                        visuals.fg_stroke
311                    } else {
312                        visuals.bg_stroke
313                    };
314                    ui.painter().rect(
315                        response.rect.expand(visuals.expansion),
316                        visuals.corner_radius,
317                        fill,
318                        stroke,
319                        egui::StrokeKind::Middle,
320                    );
321                }
322
323                let text_pos = ui
324                    .layout()
325                    .align_size_within_rect(text.size(), response.rect.shrink2(padding))
326                    .min;
327
328                ui.painter().add(egui::epaint::TextShape::new(
329                    text_pos,
330                    text,
331                    visuals.fg_stroke.color,
332                ));
333            }
334        }
335    }
336}
337
338impl<P: Param> Widget for ParamSlider<'_, P> {
339    fn ui(mut self, ui: &mut Ui) -> Response {
340        let slider_width = self
341            .slider_width
342            .unwrap_or_else(|| ui.spacing().slider_width);
343
344        ui.horizontal(|ui| {
345            // Allocate space, but add some padding on the top and bottom to make it look a bit slimmer.
346            let height = ui
347                .text_style_height(&TextStyle::Body)
348                .max(ui.spacing().interact_size.y * 0.8);
349            let slider_height = (height * 0.8).round_to_pixels(ui.painter().pixels_per_point());
350            let mut response = ui
351                .vertical(|ui| {
352                    ui.allocate_space(vec2(slider_width, (height - slider_height) / 2.0));
353                    let response = ui.allocate_response(
354                        vec2(slider_width, slider_height),
355                        Sense::click_and_drag(),
356                    );
357                    let (kb_edit_id, _) =
358                        ui.allocate_space(vec2(slider_width, (height - slider_height) / 2.0));
359                    // Allocate an automatic ID for keeping track of keyboard focus state
360                    // FIXME: There doesn't seem to be a way to generate IDs in the public API, not sure how
361                    //        you're supposed to do this
362                    self.keyboard_focus_id = Some(kb_edit_id);
363
364                    response
365                })
366                .inner;
367
368            self.slider_ui(ui, &mut response);
369            if self.draw_value {
370                self.value_ui(ui);
371            }
372
373            response
374        })
375        .inner
376    }
377}