// Ardura 2023 - ui_knob.rs - egui + nih-plug parameter widget with customization
// this ui_knob.rs is built off a2aaron's knob base as part of nyasynth and Robbert's ParamSlider code
// https://github.com/a2aaron/nyasynth/blob/canon/src/ui_knob.rs
// This is the older style of ui_knob code for older nih_plug + egui with some updates added in
// If you want the latest, egui version for nih-plug check Interleaf's version!
// https://github.com/ardura/Interleaf/blob/main/src/ui_knob.rs
use std::{
f32::consts::TAU,
ops::{Add, Mul, Sub},
};
use lazy_static::lazy_static;
use nih_plug::prelude::{Param, ParamSetter};
use nih_plug_egui::egui::{
self,
epaint::{CircleShape, PathShape},
pos2, Align2, Color32, FontId, Pos2, Rect, Response, Rgba, Rounding, Sense, Shape, Stroke, Ui,
Vec2, Widget,
};
/// When shift+dragging a parameter, one pixel dragged corresponds to this much change in the
/// noramlized parameter.
const GRANULAR_DRAG_MULTIPLIER: f32 = 0.001;
const NORMAL_DRAG_MULTIPLIER: f32 = 0.005;
lazy_static! {
static ref DRAG_NORMALIZED_START_VALUE_MEMORY_ID: egui::Id = egui::Id::new((file!(), 0));
static ref DRAG_AMOUNT_MEMORY_ID: egui::Id = egui::Id::new((file!(), 1));
static ref VALUE_ENTRY_MEMORY_ID: egui::Id = egui::Id::new((file!(), 2));
}
struct SliderRegion<'a, P: Param> {
param: &'a P,
param_setter: &'a ParamSetter<'a>,
}
impl<'a, P: Param> SliderRegion<'a, P> {
fn new(param: &'a P, param_setter: &'a ParamSetter) -> Self {
SliderRegion {
param,
param_setter,
}
}
fn set_normalized_value(&self, normalized: f32) {
// This snaps to the nearest plain value if the parameter is stepped in some way.
// TODO: As an optimization, we could add a `const CONTINUOUS: bool` to the parameter to
// avoid this normalized->plain->normalized conversion for parameters that don't need
// it
let value = self.param.preview_plain(normalized);
if value != self.plain_value() {
self.param_setter.set_parameter(self.param, value);
}
}
fn plain_value(&self) -> P::Plain {
self.param.modulated_plain_value()
}
fn normalized_value(&self) -> f32 {
self.param.modulated_normalized_value()
}
fn get_drag_normalized_start_value_memory(ui: &Ui) -> f32 {
ui.memory(|mem| mem.data.get_temp(*DRAG_NORMALIZED_START_VALUE_MEMORY_ID))
.unwrap_or(0.5)
}
fn set_drag_normalized_start_value_memory(ui: &Ui, amount: f32) {
ui.memory_mut(|mem| {
mem.data
.insert_temp(*DRAG_NORMALIZED_START_VALUE_MEMORY_ID, amount)
});
}
fn get_drag_amount_memory(ui: &Ui) -> f32 {
ui.memory(|mem| mem.data.get_temp(*DRAG_AMOUNT_MEMORY_ID))
.unwrap_or(0.0)
}
fn set_drag_amount_memory(ui: &Ui, amount: f32) {
ui.memory_mut(|mem| mem.data.insert_temp(*DRAG_AMOUNT_MEMORY_ID, amount));
}
/// Begin and end drag still need to be called when using this..
fn reset_param(&self) {
self.param_setter
.set_parameter(self.param, self.param.default_plain_value());
}
fn granular_drag(&self, ui: &Ui, drag_delta: Vec2) {
// Remember the intial position when we started with the granular drag. This value gets
// reset whenever we have a normal itneraction with the slider.
let start_value = if Self::get_drag_amount_memory(ui) == 0.0 {
Self::set_drag_normalized_start_value_memory(ui, self.normalized_value());
self.normalized_value()
} else {
Self::get_drag_normalized_start_value_memory(ui)
};
let total_drag_distance = -drag_delta.y + Self::get_drag_amount_memory(ui);
Self::set_drag_amount_memory(ui, total_drag_distance);
self.set_normalized_value(
(start_value + (total_drag_distance * GRANULAR_DRAG_MULTIPLIER)).clamp(0.0, 1.0),
);
}
// Copied this to modify the normal drag behavior to not match a slider
fn normal_drag(&self, ui: &Ui, drag_delta: Vec2) {
let start_value = if Self::get_drag_amount_memory(ui) == 0.0 {
Self::set_drag_normalized_start_value_memory(ui, self.normalized_value());
self.normalized_value()
} else {
Self::get_drag_normalized_start_value_memory(ui)
};
let total_drag_distance = -drag_delta.y + Self::get_drag_amount_memory(ui);
Self::set_drag_amount_memory(ui, total_drag_distance);
self.set_normalized_value(
(start_value + (total_drag_distance * NORMAL_DRAG_MULTIPLIER)).clamp(0.0, 1.0),
);
}
// Handle the input for a given response. Returns an f32 containing the normalized value of
// the parameter.
fn handle_response(&self, ui: &Ui, response: &mut Response) -> f32 {
// This has been replaced with the ParamSlider/CustomParamSlider structure and supporting
// functions (above) since that was still working in egui 0.22
if response.drag_started() {
// When beginning a drag or dragging normally, reset the memory used to keep track of
// our granular drag
self.param_setter.begin_set_parameter(self.param);
Self::set_drag_amount_memory(ui, 0.0);
}
if let Some(_clicked_pos) = response.interact_pointer_pos() {
if ui.input(|mem| mem.modifiers.command) {
// Like double clicking, Ctrl+Click should reset the parameter
self.reset_param();
response.mark_changed();
} else if ui.input(|mem| mem.modifiers.shift) {
// And shift dragging should switch to a more granular input method
self.granular_drag(ui, response.drag_delta());
response.mark_changed();
} else {
self.normal_drag(ui, response.drag_delta());
response.mark_changed();
//Self::set_drag_amount_memory(ui, 0.0);
}
}
if response.double_clicked() {
self.reset_param();
response.mark_changed();
}
if response.drag_released() {
self.param_setter.end_set_parameter(self.param);
}
self.normalized_value()
}
fn get_string(&self) -> String {
self.param.to_string()
}
}
pub struct ArcKnob<'a, P: Param> {
slider_region: SliderRegion<'a, P>,
radius: f32,
line_color: Color32,
fill_color: Color32,
center_size: f32,
line_width: f32,
center_to_line_space: f32,
hover_text: bool,
hover_text_content: String,
label_text: String,
show_center_value: bool,
text_size: f32,
outline: bool,
padding: f32,
show_label: bool,
swap_label_and_value: bool,
text_color_override: Color32,
readable_box: bool,
}
#[allow(dead_code)]
pub enum KnobStyle {
// Knob_line old presets
SmallTogether,
MediumThin,
LargeMedium,
SmallLarge,
SmallMedium,
SmallSmallOutline,
// Newer presets
NewPresets1,
NewPresets2,
}
#[allow(dead_code)]
impl<'a, P: Param> ArcKnob<'a, P> {
pub fn for_param(param: &'a P, param_setter: &'a ParamSetter, radius: f32) -> Self {
ArcKnob {
slider_region: SliderRegion::new(param, param_setter),
radius: radius,
line_color: Color32::BLACK,
fill_color: Color32::BLACK,
center_size: 20.0,
line_width: 2.0,
center_to_line_space: 0.0,
hover_text: false,
hover_text_content: String::new(),
text_size: 16.0,
label_text: String::new(),
show_center_value: true,
outline: false,
padding: 10.0,
show_label: true,
swap_label_and_value: true,
text_color_override: Color32::TEMPORARY_COLOR,
readable_box: true,
}
}
// Set readability box visibility for text on other colors
pub fn set_readable_box(mut self, show_box: bool) -> Self {
self.readable_box = show_box;
self
}
// Change the text color if you want it separate from line color
pub fn override_text_color(mut self, text_color: Color32) -> Self {
self.text_color_override = text_color;
self
}
// Undo newer swap label and value
pub fn set_swap_label_and_value(mut self, use_old: bool) -> Self {
self.swap_label_and_value = use_old;
self
}
// Specify outline drawing
pub fn use_outline(mut self, new_bool: bool) -> Self {
self.outline = new_bool;
self
}
// Specify showing value when mouse-over
pub fn use_hover_text(mut self, new_bool: bool) -> Self {
self.hover_text = new_bool;
self
}
// Specify value when mouse-over
pub fn set_hover_text(mut self, new_text: String) -> Self {
self.hover_text_content = new_text;
self
}
// Specify knob label
pub fn set_label(mut self, new_label: String) -> Self {
self.label_text = new_label;
self
}
// Specify line color for knob outside
pub fn set_line_color(mut self, new_color: Color32) -> Self {
self.line_color = new_color;
self
}
// Specify fill color for knob
pub fn set_fill_color(mut self, new_color: Color32) -> Self {
self.fill_color = new_color;
self
}
// Specify center knob size
pub fn set_center_size(mut self, size: f32) -> Self {
self.center_size = size;
self
}
// Specify line width
pub fn set_line_width(mut self, width: f32) -> Self {
self.line_width = width;
self
}
// Specify distance between center and arc
pub fn set_center_to_line_space(mut self, new_width: f32) -> Self {
self.center_to_line_space = new_width;
self
}
// Set text size for label
pub fn set_text_size(mut self, text_size: f32) -> Self {
self.text_size = text_size;
self
}
// Set knob padding
pub fn set_padding(mut self, padding: f32) -> Self {
self.padding = padding;
self
}
// Set center value of knob visibility
pub fn set_show_center_value(mut self, new_bool: bool) -> Self {
self.show_center_value = new_bool;
self
}
// Set center value of knob visibility
pub fn set_show_label(mut self, new_bool: bool) -> Self {
self.show_label = new_bool;
self
}
pub fn preset_style(mut self, style_id: KnobStyle) -> Self {
// These are all calculated off radius to scale better
match style_id {
KnobStyle::SmallTogether => {
self.center_size = self.radius / 4.0;
self.line_width = self.radius / 2.0;
self.center_to_line_space = 0.0;
}
KnobStyle::MediumThin => {
self.center_size = self.radius / 2.0;
self.line_width = self.radius / 8.0;
self.center_to_line_space = self.radius / 4.0;
}
KnobStyle::LargeMedium => {
self.center_size = self.radius / 1.333;
self.line_width = self.radius / 4.0;
self.center_to_line_space = self.radius / 8.0;
}
KnobStyle::SmallLarge => {
self.center_size = self.radius / 8.0;
self.line_width = self.radius / 1.333;
self.center_to_line_space = self.radius / 2.0;
}
KnobStyle::SmallMedium => {
self.center_size = self.radius / 4.0;
self.line_width = self.radius / 2.666;
self.center_to_line_space = self.radius / 1.666;
}
KnobStyle::SmallSmallOutline => {
self.center_size = self.radius / 4.0;
self.line_width = self.radius / 4.0;
self.center_to_line_space = self.radius / 4.0;
self.outline = true;
}
KnobStyle::NewPresets1 => {
self.center_size = self.radius * 0.6;
self.line_width = self.radius * 0.4;
self.center_to_line_space = self.radius * 0.0125;
self.padding = 0.0;
}
KnobStyle::NewPresets2 => {
self.center_size = self.radius * 0.5;
self.line_width = self.radius * 0.5;
self.center_to_line_space = self.radius * 0.0125;
self.padding = 0.0;
}
}
self
}
}
impl<'a, P: Param> Widget for ArcKnob<'a, P> {
fn ui(mut self, ui: &mut Ui) -> Response {
// Figure out the size to reserve on screen for widget
let desired_size = egui::vec2(
self.padding + self.radius * 2.0,
self.padding + self.radius * 2.0,
);
let mut response = ui.allocate_response(desired_size, Sense::click_and_drag());
let value = self.slider_region.handle_response(&ui, &mut response);
ui.vertical(|ui| {
let painter = ui.painter_at(response.rect);
let center = response.rect.center();
// Draw the arc
let arc_radius = self.center_size + self.center_to_line_space;
let arc_stroke = Stroke::new(self.line_width, self.line_color);
let shape = Shape::Path(PathShape {
points: get_arc_points(center, arc_radius, value, 0.03),
closed: false,
fill: Color32::TRANSPARENT,
stroke: arc_stroke,
});
painter.add(shape);
// Draw the outside ring around the control
if self.outline {
let outline_stroke = Stroke::new(1.0, self.fill_color);
let outline_shape = Shape::Path(PathShape {
points: get_arc_points(
center,
self.center_to_line_space + self.line_width,
1.0,
0.03,
),
closed: false,
fill: Color32::TRANSPARENT,
stroke: outline_stroke,
});
painter.add(outline_shape);
}
//reset stroke here so we only have fill
let line_stroke = Stroke::new(0.0, Color32::TRANSPARENT);
// Center of Knob
let circle_shape = Shape::Circle(CircleShape {
center: center,
radius: self.center_size,
stroke: line_stroke,
fill: self.fill_color,
});
painter.add(circle_shape);
// Hover text of value
if self.hover_text {
if self.hover_text_content.is_empty() {
self.hover_text_content = self.slider_region.get_string();
}
ui.allocate_rect(
Rect::from_center_size(center, Vec2::new(self.radius * 2.0, self.radius * 2.0)),
Sense::hover(),
)
.on_hover_text(self.hover_text_content);
}
// Label text from response rect bound
let label_y = if self.padding == 0.0 {
6.0
} else {
self.padding * 2.0
};
if self.show_label {
let value_pos: Pos2;
let label_pos: Pos2;
if self.swap_label_and_value {
// Newer rearranged positions to put value at bottom of knob
value_pos = Pos2::new(
response.rect.center_bottom().x,
response.rect.center_bottom().y - label_y,
);
label_pos = Pos2::new(response.rect.center().x, response.rect.center().y);
} else {
// The old value and label positions
label_pos = Pos2::new(
response.rect.center_bottom().x,
response.rect.center_bottom().y - label_y,
);
value_pos = Pos2::new(response.rect.center().x, response.rect.center().y);
}
if self.readable_box {
// Background for text readability
let readability_box = Rect::from_two_pos(
response.rect.left_bottom(),
Pos2 {
x: response.rect.right_bottom().x,
y: response.rect.right_bottom().y - 12.0,
},
);
ui.painter().rect_filled(
readability_box,
Rounding::from(16.0),
self.fill_color,
);
}
let text_color: Color32;
// Setting text color
if self.text_color_override != Color32::TEMPORARY_COLOR {
text_color = self.text_color_override;
} else {
text_color = self.line_color;
}
if self.label_text.is_empty() {
painter.text(
value_pos,
Align2::CENTER_CENTER,
self.slider_region.get_string(),
FontId::proportional(self.text_size),
text_color,
);
painter.text(
label_pos,
Align2::CENTER_CENTER,
self.slider_region.param.name(),
FontId::proportional(self.text_size),
text_color,
);
} else {
painter.text(
value_pos,
Align2::CENTER_CENTER,
self.label_text,
FontId::proportional(self.text_size),
text_color,
);
painter.text(
label_pos,
Align2::CENTER_CENTER,
self.slider_region.param.name(),
FontId::proportional(self.text_size),
text_color,
);
}
}
});
response
}
}
fn get_arc_points(center: Pos2, radius: f32, value: f32, max_arc_distance: f32) -> Vec<Pos2> {
let start_turns: f32 = 0.625;
let arc_length = lerp(0.0, -0.75, value);
let end_turns = start_turns + arc_length;
let points = (arc_length.abs() / max_arc_distance).ceil() as usize;
let points = points.max(1);
(0..=points)
.map(|i| {
let t = i as f32 / (points - 1) as f32;
let angle = lerp(start_turns * TAU, end_turns * TAU, t);
let x = radius * angle.cos();
let y = -radius * angle.sin();
pos2(x, y) + center.to_vec2()
})
.collect()
}
// Moved lerp to this file to reduce dependencies - Ardura
pub fn lerp<T>(start: T, end: T, t: f32) -> T
where
T: Add<T, Output = T> + Sub<T, Output = T> + Mul<f32, Output = T> + Copy,
{
(end - start) * t.clamp(0.0, 1.0) + start
}
pub struct TextSlider<'a, P: Param> {
slider_region: SliderRegion<'a, P>,
location: Rect,
}
#[allow(dead_code)]
impl<'a, P: Param> TextSlider<'a, P> {
pub fn for_param(param: &'a P, param_setter: &'a ParamSetter, location: Rect) -> Self {
TextSlider {
slider_region: SliderRegion::new(param, param_setter),
location,
}
}
}
impl<'a, P: Param> Widget for TextSlider<'a, P> {
fn ui(self, ui: &mut Ui) -> Response {
let mut response = ui.allocate_rect(self.location, Sense::click_and_drag());
self.slider_region.handle_response(&ui, &mut response);
let painter = ui.painter_at(self.location);
let center = self.location.center();
// Draw the text
let text = self.slider_region.get_string();
let anchor = Align2::CENTER_CENTER;
let color = Color32::from(Rgba::WHITE);
let font = FontId::monospace(16.0);
painter.text(center, anchor, text, font, color);
response
}
}