use crate::Theme;
use egui::{Response, Sense, Ui, Vec2, Widget};
use egui_cha::ViewCtx;
use std::f32::consts::PI;
use std::ops::RangeInclusive;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum KnobSize {
Compact,
#[default]
Medium,
Large,
}
pub struct Knob<'a> {
range: RangeInclusive<f64>,
label: Option<&'a str>,
size: KnobSize,
show_value: bool,
disabled: bool,
arc_start: f32,
arc_end: f32,
}
impl<'a> Knob<'a> {
pub fn new(range: RangeInclusive<f64>) -> Self {
Self {
range,
label: None,
size: KnobSize::default(),
show_value: true,
disabled: false,
arc_start: -0.75 * PI, arc_end: 0.75 * PI, }
}
pub fn label(mut self, label: &'a str) -> Self {
self.label = Some(label);
self
}
pub fn compact(mut self) -> Self {
self.size = KnobSize::Compact;
self
}
pub fn size(mut self, size: KnobSize) -> Self {
self.size = size;
self
}
pub fn show_value(mut self, show: bool) -> Self {
self.show_value = show;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn show_with<Msg>(
self,
ctx: &mut ViewCtx<'_, Msg>,
value: f64,
on_change: impl FnOnce(f64) -> Msg,
) {
let mut current = value;
let response = self.show_internal(ctx.ui, &mut current);
if response.changed() {
ctx.emit(on_change(current));
}
}
pub fn show(self, ui: &mut Ui, value: &mut f64) -> Response {
self.show_internal(ui, value)
}
fn show_internal(self, ui: &mut Ui, value: &mut f64) -> Response {
let theme = Theme::current(ui.ctx());
let diameter = match self.size {
KnobSize::Compact => theme.spacing_lg + theme.spacing_md, KnobSize::Medium => theme.spacing_xl + theme.spacing_md, KnobSize::Large => theme.spacing_xl + theme.spacing_lg, };
let label_height = if self.label.is_some() {
theme.font_size_xs + theme.spacing_xs
} else {
0.0
};
let value_height = if self.show_value {
theme.font_size_xs + theme.spacing_xs
} else {
0.0
};
let total_height = diameter + label_height + value_height;
let (rect, mut response) = ui.allocate_exact_size(
Vec2::new(diameter, total_height),
if self.disabled {
Sense::hover()
} else {
Sense::click_and_drag()
},
);
if response.dragged() && !self.disabled {
let delta = response.drag_delta();
let sensitivity = 0.005 * (self.range.end() - self.range.start());
*value = (*value - delta.y as f64 * sensitivity)
.clamp(*self.range.start(), *self.range.end());
}
if response.double_clicked() && !self.disabled {
*value = (*self.range.start() + *self.range.end()) / 2.0;
}
if ui.is_rect_visible(rect) {
let painter = ui.painter();
let knob_center = rect.center_top() + Vec2::new(0.0, diameter / 2.0);
let radius = diameter / 2.0 - theme.spacing_xs;
let (bg_color, track_color, arc_color) = if self.disabled {
(theme.bg_tertiary, theme.border, theme.text_muted)
} else if response.hovered() || response.dragged() {
(theme.bg_tertiary, theme.border, theme.primary_hover)
} else {
(theme.bg_secondary, theme.border, theme.primary)
};
painter.circle_filled(knob_center, radius, bg_color);
let stroke_width = theme.stroke_width * 3.0;
self.draw_arc(
painter,
knob_center,
radius - stroke_width,
self.arc_start,
self.arc_end,
egui::Stroke::new(stroke_width, track_color),
);
let normalized =
(*value - *self.range.start()) / (*self.range.end() - *self.range.start());
let value_angle = self.arc_start + (self.arc_end - self.arc_start) * normalized as f32;
if normalized > 0.001 {
self.draw_arc(
painter,
knob_center,
radius - stroke_width,
self.arc_start,
value_angle,
egui::Stroke::new(stroke_width, arc_color),
);
}
let dot_radius = theme.spacing_xs / 2.0;
let dot_distance = radius - stroke_width * 2.5;
let dot_pos = knob_center
+ Vec2::new(
value_angle.sin() * dot_distance,
-value_angle.cos() * dot_distance,
);
painter.circle_filled(dot_pos, dot_radius, arc_color);
if self.show_value {
let value_text = if *self.range.end() - *self.range.start() > 10.0 {
format!("{:.0}", value)
} else {
format!("{:.2}", value)
};
painter.text(
knob_center,
egui::Align2::CENTER_CENTER,
&value_text,
egui::FontId::proportional(theme.font_size_xs),
if self.disabled {
theme.text_muted
} else {
theme.text_primary
},
);
}
if let Some(label) = self.label {
let label_pos = rect.center_bottom() - Vec2::new(0.0, theme.font_size_xs / 2.0);
painter.text(
label_pos,
egui::Align2::CENTER_CENTER,
label,
egui::FontId::proportional(theme.font_size_xs),
if self.disabled {
theme.text_muted
} else {
theme.text_secondary
},
);
}
}
if response.dragged() || response.double_clicked() {
response.mark_changed();
}
response
}
fn draw_arc(
&self,
painter: &egui::Painter,
center: egui::Pos2,
radius: f32,
start_angle: f32,
end_angle: f32,
stroke: egui::Stroke,
) {
let segments = 32;
let angle_step = (end_angle - start_angle) / segments as f32;
let points: Vec<egui::Pos2> = (0..=segments)
.map(|i| {
let angle = start_angle + angle_step * i as f32;
center + Vec2::new(angle.sin() * radius, -angle.cos() * radius)
})
.collect();
for i in 0..points.len() - 1 {
painter.line_segment([points[i], points[i + 1]], stroke);
}
}
}
impl Widget for Knob<'_> {
fn ui(self, ui: &mut Ui) -> Response {
let mut dummy = 0.5; self.show_internal(ui, &mut dummy)
}
}