diff --git a/plugins/diopser/src/editor.rs b/plugins/diopser/src/editor.rs index 7f3f3e8c..1faa0318 100644 --- a/plugins/diopser/src/editor.rs +++ b/plugins/diopser/src/editor.rs @@ -23,6 +23,7 @@ use nih_plug_vizia::{assets, create_vizia_editor, ViziaState, ViziaTheming}; use std::sync::{Arc, Mutex}; use self::button::SafeModeButton; +use self::slider::RestrictedParamSlider; use crate::params::DiopserParams; use crate::spectrum::SpectrumOutput; use crate::Diopser; @@ -30,6 +31,7 @@ use crate::Diopser; mod analyzer; mod button; mod safe_mode; +mod slider; mod xy_pad; pub use safe_mode::SafeModeClamper; @@ -173,7 +175,12 @@ fn other_params(cx: &mut Context) { VStack::new(cx, |cx| { HStack::new(cx, move |cx| { Label::new(cx, "Filter Stages").class("param-label"); - ParamSlider::new(cx, Data::params, |params| ¶ms.filter_stages); + RestrictedParamSlider::new( + cx, + Data::params, + |params| ¶ms.filter_stages, + Data::safe_mode_clamper, + ); }) .bottom(Pixels(10.0)); diff --git a/plugins/diopser/src/editor/slider.rs b/plugins/diopser/src/editor/slider.rs new file mode 100644 index 00000000..2a1f08f5 --- /dev/null +++ b/plugins/diopser/src/editor/slider.rs @@ -0,0 +1,467 @@ +// Diopser: a phase rotation plugin +// Copyright (C) 2021-2022 Robbert van der Helm +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! A modified version of the regular `ParamSlider` that works with Diopser's safe mode. + +use nih_plug::prelude::Param; +use nih_plug_vizia::vizia::prelude::*; +use nih_plug_vizia::widgets::param_base::ParamWidgetBase; +use nih_plug_vizia::widgets::util::{self, ModifiersExt}; + +use super::SafeModeClamper; + +/// When shift+dragging a parameter, one pixel dragged corresponds to this much change in the +/// normalized parameter. +const GRANULAR_DRAG_MULTIPLIER: f32 = 0.1; + +/// A simplified version of `ParamSlider` that works with Diopser's safe mode. The slider's range is +/// restricted when safe mode is enabled. +#[derive(Lens)] +pub struct RestrictedParamSlider> { + safe_mode: LSafeMode, + param_base: ParamWidgetBase, + + /// Will be set to `true` when the field gets Alt+Click'ed which will replace the label with a + /// text box. + text_input_active: bool, + /// Will be set to `true` if we're dragging the parameter. Resetting the parameter or entering a + /// text value should not initiate a drag. + drag_active: bool, + /// We keep track of the start coordinate and normalized value when holding down Shift while + /// dragging for higher precision dragging. This is a `None` value when granular dragging is not + /// active. + granular_drag_status: Option, + + // These fields are set through modifiers: + /// Whether or not to listen to scroll events for changing the parameter's value in steps. + use_scroll_wheel: bool, + /// The number of (fractional) scrolled lines that have not yet been turned into parameter + /// change events. This is needed to support trackpads with smooth scrolling. + scrolled_lines: f32, +} + +enum ParamSliderEvent { + /// Text input has been cancelled without submitting a new value. + CancelTextInput, + /// A new value has been sent by the text input dialog after pressing Enter. + TextInput(String), +} + +#[derive(Debug, Clone, Copy)] +pub struct GranularDragStatus { + /// The mouse's X-coordinate when the granular drag was started. + pub starting_x_coordinate: f32, + /// The normalized value when the granular drag was started. + pub starting_value: f32, +} + +impl> RestrictedParamSlider { + /// See the original `ParamSlider`. + pub fn new( + cx: &mut Context, + params: LParams, + params_to_param: FMap, + safe_mode: LSafeMode, + ) -> Handle + where + LParams: Lens + Clone, + Params: 'static, + P: Param + 'static, + FMap: Fn(&Params) -> &P + Copy + 'static, + { + // We'll visualize the difference between the current value and the default value if the + // default value lies somewhere in the middle and the parameter is continuous. Otherwise + // this approach looks a bit jarring. + Self { + safe_mode, + param_base: ParamWidgetBase::new(cx, params.clone(), params_to_param), + + text_input_active: false, + drag_active: false, + granular_drag_status: None, + + use_scroll_wheel: true, + scrolled_lines: 0.0, + } + .build( + cx, + ParamWidgetBase::build_view(params, params_to_param, move |cx, param_data| { + // Can't use `.to_string()` here as that would include the modulation. + let unmodulated_normalized_value_lens = + param_data.make_lens(|param| param.unmodulated_normalized_value()); + let display_value_lens = param_data.make_lens(|param| { + param.normalized_value_to_string(param.unmodulated_normalized_value(), true) + }); + + // The resulting tuple `(start_t, delta)` corresponds to the start and the + // signed width of the bar. `start_t` is in `[0, 1]`, and `delta` is in + // `[-1, 1]`. + let fill_start_delta_lens = { + let param_data = param_data.clone(); + unmodulated_normalized_value_lens.map(move |current_value| { + Self::compute_fill_start_delta(param_data.param(), *current_value) + }) + }; + + // If the parameter is being modulated by the host (this only works for CLAP + // plugins with hosts that support this), then this is the difference + // between the 'true' value and the current value after modulation has been + // applied. This follows the same format as `fill_start_delta_lens`. + let modulation_start_delta_lens = param_data + .make_lens(move |param| Self::compute_modulation_fill_start_delta(param)); + + // Only draw the text input widget when it gets focussed. Otherwise, overlay the + // label with the slider. Creating the textbox based on + // `ParamSliderInternal::text_input_active` lets us focus the textbox when it gets + // created. + Binding::new( + cx, + RestrictedParamSlider::::text_input_active, + move |cx, text_input_active| { + if text_input_active.get(cx) { + Self::text_input_view(cx, display_value_lens.clone()); + } else { + // All of this data needs to be moved into the `ZStack` closure, and + // the `Map` lens combinator isn't `Copy` + let fill_start_delta_lens = fill_start_delta_lens.clone(); + let modulation_start_delta_lens = modulation_start_delta_lens.clone(); + let display_value_lens = display_value_lens.clone(); + + ZStack::new(cx, move |cx| { + Self::slider_fill_view( + cx, + fill_start_delta_lens, + modulation_start_delta_lens, + ); + Self::slider_label_view(cx, display_value_lens); + }) + .hoverable(false); + } + }, + ); + }), + ) + } + + /// Create a text input that's shown in place of the slider. + fn text_input_view(cx: &mut Context, display_value_lens: impl Lens) { + Textbox::new(cx, display_value_lens) + .class("value-entry") + .on_submit(|cx, string, success| { + if success { + cx.emit(ParamSliderEvent::TextInput(string)) + } else { + cx.emit(ParamSliderEvent::CancelTextInput); + } + }) + .on_build(|cx| { + cx.emit(TextEvent::StartEdit); + cx.emit(TextEvent::SelectAll); + }) + // `.child_space(Stretch(1.0))` no longer works + .class("align_center") + .child_top(Stretch(1.0)) + .child_bottom(Stretch(1.0)) + .height(Stretch(1.0)) + .width(Stretch(1.0)); + } + + /// Create the fill part of the slider. + fn slider_fill_view( + cx: &mut Context, + fill_start_delta_lens: impl Lens, + modulation_start_delta_lens: impl Lens, + ) { + // The filled bar portion. This can be visualized in a couple different ways depending on + // the current style property. See [`ParamSliderStyle`]. + Element::new(cx) + .class("fill") + .height(Stretch(1.0)) + .left( + fill_start_delta_lens + .clone() + .map(|(start_t, _)| Percentage(start_t * 100.0)), + ) + .width(fill_start_delta_lens.map(|(_, delta)| Percentage(delta * 100.0))) + // Hovering is handled on the param slider as a whole, this + // should not affect that + .hoverable(false); + + // If the parameter is being modulated, then we'll display another + // filled bar showing the current modulation delta + // VIZIA's bindings make this a bit, uh, difficult to read + Element::new(cx) + .class("fill") + .class("fill--modulation") + .height(Stretch(1.0)) + .visibility( + modulation_start_delta_lens + .clone() + .map(|(_, delta)| *delta != 0.0), + ) + // Widths cannot be negative, so we need to compensate the start + // position if the width does happen to be negative + .width( + modulation_start_delta_lens + .clone() + .map(|(_, delta)| Percentage(delta.abs() * 100.0)), + ) + .left(modulation_start_delta_lens.map(|(start_t, delta)| { + if *delta < 0.0 { + Percentage((start_t + delta) * 100.0) + } else { + Percentage(start_t * 100.0) + } + })) + .hoverable(false); + } + + /// Create the text part of the slider. Shown on top of the fill using a `ZStack`. + fn slider_label_view(cx: &mut Context, display_value_lens: impl Lens) { + Label::new(cx, display_value_lens) + .class("value") + .class("value--single") + .child_space(Stretch(1.0)) + .height(Stretch(1.0)) + .width(Stretch(1.0)) + .hoverable(false); + } + + /// Calculate the start position and width of the slider's fill region based on the selected + /// style, the parameter's current value, and the parameter's step sizes. The resulting tuple + /// `(start_t, delta)` corresponds to the start and the signed width of the bar. `start_t` is in + /// `[0, 1]`, and `delta` is in `[-1, 1]`. + fn compute_fill_start_delta(param: &P, current_value: f32) -> (f32, f32) { + let default_value = param.default_normalized_value(); + let step_count = param.step_count(); + let draw_fill_from_default = step_count.is_none() && (0.45..=0.55).contains(&default_value); + + if draw_fill_from_default { + let delta = (default_value - current_value).abs(); + + // Don't draw the filled portion at all if it could have been a + // rounding error since those slivers just look weird + ( + default_value.min(current_value), + if delta >= 1e-3 { delta } else { 0.0 }, + ) + } else { + (0.0, current_value) + } + } + + /// The same as `compute_fill_start_delta`, but just showing the modulation offset. + fn compute_modulation_fill_start_delta(param: &P) -> (f32, f32) { + let modulation_start = param.unmodulated_normalized_value(); + + ( + modulation_start, + param.modulated_normalized_value() - modulation_start, + ) + } + + /// `self.param_base.set_normalized_value()`, but resulting from a mouse drag. When using the + /// 'even' stepped slider styles from [`ParamSliderStyle`] this will remap the normalized range + /// to match up with the fill value display. This still needs to be wrapped in a parameter + /// automation gesture. + fn set_normalized_value_drag(&self, cx: &mut EventContext, normalized_value: f32) { + self.param_base.set_normalized_value(cx, normalized_value); + } +} + +impl> View for RestrictedParamSlider { + fn element(&self) -> Option<&'static str> { + // We'll reuse the original ParamSlider's styling + Some("param-slider") + } + + fn event(&mut self, cx: &mut EventContext, event: &mut Event) { + event.map(|param_slider_event, meta| match param_slider_event { + ParamSliderEvent::CancelTextInput => { + self.text_input_active = false; + cx.set_active(false); + + meta.consume(); + } + ParamSliderEvent::TextInput(string) => { + if let Some(normalized_value) = self.param_base.string_to_normalized_value(string) { + self.param_base.begin_set_parameter(cx); + self.param_base.set_normalized_value(cx, normalized_value); + self.param_base.end_set_parameter(cx); + } + + self.text_input_active = false; + + meta.consume(); + } + }); + + event.map(|window_event, meta| match window_event { + // Vizia always captures the third mouse click as a triple click. Treating that triple + // click as a regular mouse button makes double click followed by another drag work as + // expected, instead of requiring a delay or an additional click. Double double click + // still won't work. + WindowEvent::MouseDown(MouseButton::Left) + | WindowEvent::MouseTripleClick(MouseButton::Left) => { + if cx.modifiers.alt() { + // ALt+Click brings up a text entry dialog + self.text_input_active = true; + cx.set_active(true); + } else if cx.modifiers.command() { + // Ctrl+Click, double click, and right clicks should reset the parameter instead + // of initiating a drag operation + self.param_base.begin_set_parameter(cx); + self.param_base + .set_normalized_value(cx, self.param_base.default_normalized_value()); + self.param_base.end_set_parameter(cx); + } else { + self.drag_active = true; + cx.capture(); + // NOTE: Otherwise we don't get key up events + cx.focus(); + cx.set_active(true); + + // When holding down shift while clicking on a parameter we want to granuarly + // edit the parameter without jumping to a new value + self.param_base.begin_set_parameter(cx); + if cx.modifiers.shift() { + self.granular_drag_status = Some(GranularDragStatus { + starting_x_coordinate: cx.mouse.cursorx, + starting_value: self.param_base.unmodulated_normalized_value(), + }); + } else { + self.granular_drag_status = None; + self.set_normalized_value_drag( + cx, + util::remap_current_entity_x_coordinate(cx, cx.mouse.cursorx), + ); + } + } + + meta.consume(); + } + WindowEvent::MouseDoubleClick(MouseButton::Left) + | WindowEvent::MouseDown(MouseButton::Right) + | WindowEvent::MouseDoubleClick(MouseButton::Right) + | WindowEvent::MouseTripleClick(MouseButton::Right) => { + // Ctrl+Click, double click, and right clicks should reset the parameter instead of + // initiating a drag operation + self.param_base.begin_set_parameter(cx); + self.param_base + .set_normalized_value(cx, self.param_base.default_normalized_value()); + self.param_base.end_set_parameter(cx); + + meta.consume(); + } + WindowEvent::MouseUp(MouseButton::Left) => { + if self.drag_active { + self.drag_active = false; + cx.release(); + cx.set_active(false); + + self.param_base.end_set_parameter(cx); + + meta.consume(); + } + } + WindowEvent::MouseMove(x, _y) => { + if self.drag_active { + // If shift is being held then the drag should be more granular instead of + // absolute + if cx.modifiers.shift() { + let granular_drag_status = + *self + .granular_drag_status + .get_or_insert_with(|| GranularDragStatus { + starting_x_coordinate: *x, + starting_value: self.param_base.unmodulated_normalized_value(), + }); + + // These positions should be compensated for the DPI scale so it remains + // consistent + let start_x = + util::remap_current_entity_x_t(cx, granular_drag_status.starting_value); + let delta_x = ((*x - granular_drag_status.starting_x_coordinate) + * GRANULAR_DRAG_MULTIPLIER) + * cx.style.dpi_factor as f32; + + self.set_normalized_value_drag( + cx, + util::remap_current_entity_x_coordinate(cx, start_x + delta_x), + ); + } else { + self.granular_drag_status = None; + + self.set_normalized_value_drag( + cx, + util::remap_current_entity_x_coordinate(cx, *x), + ); + } + } + } + WindowEvent::KeyUp(_, Some(Key::Shift)) => { + // If this happens while dragging, snap back to reality uh I mean the current screen + // position + if self.drag_active && self.granular_drag_status.is_some() { + self.granular_drag_status = None; + self.param_base.set_normalized_value( + cx, + util::remap_current_entity_x_coordinate(cx, cx.mouse.cursorx), + ); + } + } + WindowEvent::MouseScroll(_scroll_x, scroll_y) if self.use_scroll_wheel => { + // With a regular scroll wheel `scroll_y` will only ever be -1 or 1, but with smooth + // scrolling trackpads being a thing `scroll_y` could be anything. + self.scrolled_lines += scroll_y; + + if self.scrolled_lines.abs() >= 1.0 { + let use_finer_steps = cx.modifiers.shift(); + + // Scrolling while dragging needs to be taken into account here + if !self.drag_active { + self.param_base.begin_set_parameter(cx); + } + + let mut current_value = self.param_base.unmodulated_normalized_value(); + + while self.scrolled_lines >= 1.0 { + current_value = self + .param_base + .next_normalized_step(current_value, use_finer_steps); + self.param_base.set_normalized_value(cx, current_value); + self.scrolled_lines -= 1.0; + } + + while self.scrolled_lines <= -1.0 { + current_value = self + .param_base + .previous_normalized_step(current_value, use_finer_steps); + self.param_base.set_normalized_value(cx, current_value); + self.scrolled_lines += 1.0; + } + + if !self.drag_active { + self.param_base.end_set_parameter(cx); + } + } + + meta.consume(); + } + _ => {} + }); + } +}