diff --git a/.gitignore b/.gitignore index 0a23edc..71336b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,14 @@ +# Rust /target -/examples/target \ No newline at end of file +/examples/target + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index ee0ae94..2bb6aea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. +## [0.3.5] - 2025-07-15 + +### 🛠 Maintenance + +- Restructured the project + +### 📚 Documentation + +- Updated readme, added contributing guidelines + ## [0.3.4] - 2025-07-15 ### 🛠 Maintenance diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..788df57 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# Contributing to egui_knob + +## Getting Started + +1. Fork the repository +2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/egui_knob.git` +3. Create a new branch: `git checkout -b feature/your-feature-name` +4. Make your changes +5. Test your changes +6. Commit your changes +7. Push to the branch: `git push origin feature/your-feature-name` +8. Submit a pull request + +## Development + +### Building + +```bash +cargo build +``` + +## Running demo app + +```bash +cargo run --example example_knob +``` + +## Code Style + +- Follow Rust standard formatting (use `cargo fmt`) +- Write clear, descriptive commit messages +- Add documentation for public APIs +- Keep changes focused and atomic + +## Pull Request Guidelines + +- Add examples for new features +- Keep PRs focused on a single feature or fix +- Reference any related issues + +## Reporting Issues + +When reporting issues, please include: +- A clear description of the problem +- Steps to reproduce +- Expected vs actual behavior +- Your environment (OS, Rust version) +- Minimal code example if applicable diff --git a/Cargo.lock b/Cargo.lock index 54349b5..224ab51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -869,7 +869,7 @@ dependencies = [ [[package]] name = "egui_knob" -version = "0.3.4" +version = "0.3.5" dependencies = [ "eframe", "egui", diff --git a/Cargo.toml b/Cargo.toml index b0a9096..644badd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "egui_knob" -version = "0.3.4" +version = "0.3.5" edition = "2024" +authors = ["Daniel Dada"] description = "A simple knob widget for egui" homepage = "https://github.com/obsqrbtz/egui_knob" repository = "https://github.com/obsqrbtz/egui_knob" +documentation = "https://docs.rs/egui_knob" readme = "README.md" license = "MIT" keywords = ["egui", "ui", "widget", "knob", "range"] diff --git a/README.md b/README.md index ff3f39e..019645b 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,23 @@ # egui_knob -![Crates.io Version](https://img.shields.io/crates/v/egui_knob) +[![Crates.io](https://img.shields.io/crates/v/egui_knob)](https://crates.io/crates/egui_knob) +[![Documentation](https://docs.rs/egui_knob/badge.svg)](https://docs.rs/egui_knob) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -Simple knob widget for egui. +A simple, customizable knob widget for egui. ![Knob Widget Screenshot](scrot.png) ## Features -- Adjustable size, font size, and stroke width. -- Customizable colors for the knob, indicator and text. -- Label positions (Top, Bottom, Left, Right). -- Label formatting. -- Two styles: Wiper and Dot. +- Adjustable size, font size, and stroke width +- Customizable colors for the knob, indicator, and text +- Label positions (Top, Bottom, Left, Right) +- Custom label formatting +- Two visual styles: Wiper and Dot +- Configurable sweep range +- Background arc with filled segments +- Adjustable drag sensitivity ## Installation @@ -22,14 +27,16 @@ To use the Knob widget in your project, add the following to your `Cargo.toml`: [dependencies] egui = "0.33" eframe = "0.33" -egui_knob = "0.3.4" +egui_knob = "0.3.5" ``` -## Usage example +## Usage + +### Basic Example ```rust use egui_knob::{Knob, KnobStyle, LabelPosition}; -use eframe::{egui}; +use eframe::egui; struct KnobApp { value: f32, @@ -56,12 +63,55 @@ impl eframe::App for KnobApp { } } -fn main() { - let options = eframe::NativeOptions::default(); +fn main() -> eframe::Result<()> { eframe::run_native( - "Minimal", - options, - Box::new(|_cc| Ok(Box::new(KnobApp::default()) as Box)), - ).unwrap(); + "Knob Example", + eframe::NativeOptions::default(), + Box::new(|_cc| Ok(Box::new(KnobApp::default()))), + ) } ``` + +### Advanced Examples + +#### Custom Sweep Range +```rust +// 270° sweep starting from the left (9 o'clock position) +Knob::new(&mut value, 0.0, 100.0, KnobStyle::Wiper) + .with_sweep_range(0.25, 0.75) + .with_label("Gain", LabelPosition::Bottom); +``` + +#### Multi-Turn Knob +```rust +// 2.5 full rotations +Knob::new(&mut value, 0.0, 1.0, KnobStyle::Dot) + .with_sweep_range(0.0, 2.5); +``` + +#### Stepped Values +```rust +// Snap to 0.1 increments +Knob::new(&mut value, 0.0, 1.0, KnobStyle::Wiper) + .with_step(Some(0.1)) + .with_label_format(|v| format!("{:.1}", v)); +``` + +#### Custom Formatting +```rust +// Display as percentage +Knob::new(&mut value, 0.0, 1.0, KnobStyle::Wiper) + .with_label_format(|v| format!("{:.0}%", v * 100.0)); +``` + +## Running demo app + +```bash +cargo run --example example_knob +``` + +Demo app is available at [examples/example_knob.rs](examples/example_knob.rs). + +## Contributing + +Contributions are welcome. Feel free to open an issue or submit a PR. diff --git a/examples/example_knob.rs b/examples/example_knob.rs index 23b3af3..fdd79ae 100644 --- a/examples/example_knob.rs +++ b/examples/example_knob.rs @@ -2,9 +2,16 @@ use eframe::egui; use egui_knob::{Knob, KnobStyle, LabelPosition}; fn main() -> eframe::Result<()> { + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([800.0, 600.0]) + .with_title("egui_knob demo"), + ..Default::default() + }; + eframe::run_native( "egui_knob demo", - eframe::NativeOptions::default(), + options, Box::new(|_cc| Ok(Box::new(KnobDemo::default()))), ) } @@ -36,24 +43,27 @@ impl Default for KnobDemo { impl eframe::App for KnobDemo { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { egui::CentralPanel::default().show(ctx, |ui| { - ui.heading("egui-knob example"); + ui.heading("egui_knob demo"); ui.separator(); ui.horizontal(|ui| { - ui.checkbox(&mut self.show_bg_arc, "Show background arc"); - ui.checkbox(&mut self.show_filled, "Show filled segment"); - ui.checkbox(&mut self.use_step, "Enable step (0.1)"); + ui.label("Global Settings:"); + ui.checkbox(&mut self.show_bg_arc, "Background arc"); + ui.checkbox(&mut self.show_filled, "Filled segment"); + ui.checkbox(&mut self.use_step, "Step (0.02)"); }); ui.horizontal(|ui| { - ui.label("Knob Colors:"); + ui.label("Color Theme:"); ui.color_edit_button_srgba(&mut self.knob_color); + ui.label("Knob"); ui.color_edit_button_srgba(&mut self.line_color); + ui.label("Indicator"); ui.color_edit_button_srgba(&mut self.text_color); + ui.label("Text"); }); ui.separator(); - ui.label("👇 Scroll or drag knobs to interact:"); ui.add_space(10.0); egui::Grid::new("knob_grid") @@ -78,7 +88,7 @@ impl eframe::App for KnobDemo { .with_background_arc(self.show_bg_arc) .with_show_filled_segments(self.show_filled) .with_colors(self.knob_color, self.line_color, self.text_color) - .with_step(self.use_step.then_some(0.1)); + .with_step(self.use_step.then_some(0.02)); if *label == "Wiper, Sweep" { knob = knob.with_sweep_range(0.25, 0.75).with_size(50.0); diff --git a/scrot.png b/scrot.png index 852bf23..d45350d 100644 Binary files a/scrot.png and b/scrot.png differ diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..8f85683 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,41 @@ +use crate::style::{KnobColors, KnobStyle, LabelPosition}; + +pub struct KnobConfig { + pub(crate) size: f32, + pub(crate) font_size: f32, + pub(crate) stroke_width: f32, + pub(crate) colors: KnobColors, + pub(crate) label: Option, + pub(crate) label_position: LabelPosition, + pub(crate) style: KnobStyle, + pub(crate) label_offset: f32, + pub(crate) label_format: Box String>, + pub(crate) step: Option, + pub(crate) drag_sensitivity: f32, + pub(crate) show_background_arc: bool, + pub(crate) show_filled_segments: bool, + pub(crate) min_angle: f32, + pub(crate) max_angle: f32, +} + +impl KnobConfig { + pub fn new(style: KnobStyle) -> Self { + Self { + size: 40.0, + font_size: 12.0, + stroke_width: 2.0, + colors: KnobColors::default(), + label: None, + label_position: LabelPosition::Bottom, + style, + label_offset: 1.0, + label_format: Box::new(|v| format!("{:.2}", v)), + step: None, + min_angle: -std::f32::consts::PI, + max_angle: std::f32::consts::PI * 0.5, + drag_sensitivity: 0.005, + show_background_arc: true, + show_filled_segments: true, + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 002110a..eb4f18f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,412 +1,7 @@ -use core::f32; +mod config; +mod render; +mod style; +mod widget; -use egui::{Align2, Color32, Rect, Response, Sense, Stroke, Ui, Vec2, Widget}; - -/// Position of the label relative to the knob -pub enum LabelPosition { - Top, - Bottom, - Left, - Right, -} - -/// Visual style of the knob indicator -#[derive(Clone, Copy)] // <--- add this! -pub enum KnobStyle { - /// A line extending from the center to the edge - Wiper, - /// A dot on the edge of the knob - Dot, -} - -/// A circular knob widget for egui that can be dragged to change a value -/// -/// # Example -/// ``` -/// let mut value = 0.5; -/// Knob::new(&mut value, 0.0, 1.0, KnobStyle::Wiper) -/// .with_size(50.0) -/// .with_label("Volume", LabelPosition::Bottom) -/// .with_step(0.1); -/// ``` -pub struct Knob<'a> { - value: &'a mut f32, - min: f32, - max: f32, - size: f32, - font_size: f32, - stroke_width: f32, - knob_color: Color32, - line_color: Color32, - text_color: Color32, - label: Option, - label_position: LabelPosition, - style: KnobStyle, - label_offset: f32, - label_format: Box String>, - step: Option, - drag_sensitivity: f32, - show_background_arc: bool, - show_filled_segments: bool, - - /// Minimum angle in radians. - /// Specifies the lower bound of the knob's rotation. - /// Expected range: 0.0 to TAU. Values outside this range are allowed if your use case requires it. - min_angle: f32, - - /// Maximum angle in radians. - /// Specifies the upper bound of the knob's rotation. - /// Can be any value > `min_angle` - /// range `max_angle` - `min_angle` > TAU is allowed but will induce multi-turn - max_angle: f32, -} - -impl<'a> Knob<'a> { - /// Creates a new knob widget - /// - /// # Arguments - /// * `value` - Mutable reference to the value controlled by the knob - /// * `min` - Minimum value - /// * `max` - Maximum value - /// * `style` - Visual style of the knob indicator - pub fn new(value: &'a mut f32, min: f32, max: f32, style: KnobStyle) -> Self { - Self { - value, - min, - max, - size: 40.0, - font_size: 12.0, - stroke_width: 2.0, - knob_color: Color32::GRAY, - line_color: Color32::GRAY, - text_color: Color32::WHITE, - label: None, - label_position: LabelPosition::Bottom, - style, - label_offset: 1.0, - label_format: Box::new(|v| format!("{:.2}", v)), - step: None, - - // Hardcode those two angles to ENSURE backward compatibility - min_angle: -std::f32::consts::PI, - max_angle: std::f32::consts::PI * 0.5, - drag_sensitivity: 0.005, - show_background_arc: true, - show_filled_segments: true, - } - } - - /// Sets the angular sweep range of the knob - /// - /// This controls where the knob starts and how far it can rotate. By default, - /// knobs start at the left (180°) and sweep 270° clockwise to bottom. - /// - /// # Arguments - /// * `start_angle_normalized` - Starting position as fraction of full circle: - /// - `0.0` = bottom (6 o'clock) - /// - `0.25` = left (9 o'clock) - /// - `0.5` = top (12 o'clock) - /// - `0.75` = right (3 o'clock) - /// * `range` - How far the knob can sweep as fraction of full circle: - /// - `0.25` = quarter turn (90°) - /// - `0.5` = half turn (180°) - /// - `0.75` = three-quarter turn (270°) - /// - `1.0` = full turn (360°) - /// - Values > 1.0 create multi-turn knobs - /// - Negative values are clamped to 0.0 - /// - /// Note: the start angle is offset by PI/2 so that `0.0` is at the bottom (6 o'clock) - pub fn with_sweep_range(mut self, start_angle_normalized: f32, range: f32) -> Self { - if start_angle_normalized.is_nan() || range.is_nan() { - return self; - } - - self.min_angle = - start_angle_normalized.rem_euclid(1.) * f32::consts::TAU + f32::consts::PI / 2.; - - // A range of 1. represent a full turn - self.max_angle = self.min_angle + range.max(0.) * f32::consts::TAU; - self - } - - /// Sets the size of the knob - pub fn with_size(mut self, size: f32) -> Self { - self.size = size; - self - } - - /// Sets the font size for the label - pub fn with_font_size(mut self, size: f32) -> Self { - self.font_size = size; - self - } - - /// Sets the stroke width for the knob's outline and indicator - pub fn with_stroke_width(mut self, width: f32) -> Self { - self.stroke_width = width; - self - } - - /// Sets the colors for different parts of the knob - /// - /// # Arguments - /// * `knob_color` - Color of the knob's outline - /// * `line_color` - Color of the indicator - /// * `text_color` - Color of the label text - pub fn with_colors( - mut self, - knob_color: Color32, - line_color: Color32, - text_color: Color32, - ) -> Self { - self.knob_color = knob_color; - self.line_color = line_color; - self.text_color = text_color; - self - } - - /// Adds a label to the knob - /// - /// # Arguments - /// * `label` - Text to display - /// * `position` - Position of the label relative to the knob - pub fn with_label(mut self, label: impl Into, position: LabelPosition) -> Self { - self.label = Some(label.into()); - self.label_position = position; - self - } - - /// Sets the spacing between the knob and its label - pub fn with_label_offset(mut self, offset: f32) -> Self { - self.label_offset = offset; - self - } - - /// Sets a custom format function for displaying the value - /// - /// # Example - /// ``` - /// # let mut value = 0.5; - /// Knob::new(&mut value, 0.0, 1.0, KnobStyle::Wiper) - /// .with_label_format(|v| format!("{:.1}%", v * 100.0)); - /// ``` - pub fn with_label_format(mut self, format: impl Fn(f32) -> String + 'static) -> Self { - self.label_format = Box::new(format); - self - } - - /// Sets the step size for value changes - /// - /// When set, the value will snap to discrete steps as the knob is dragged. - pub fn with_step(mut self, step: Option) -> Self { - self.step = step; - self - } - /// Shows the background arc showing full range - pub fn with_background_arc(mut self, enabled: bool) -> Self { - self.show_background_arc = enabled; - self - } - /// Shows filled range on the background arc - pub fn with_show_filled_segments(mut self, enabled: bool) -> Self { - self.show_filled_segments = enabled; - self - } - // Private - fn compute_angle(&self) -> f32 { - if self.min == self.max || self.value.is_nan() { - self.min_angle - } else { - self.min_angle - + (*self.value - self.min) / (self.max - self.min) - * (self.max_angle - self.min_angle) - } - } -} - -impl Widget for Knob<'_> { - fn ui(self, ui: &mut Ui) -> Response { - if self.value.is_nan() { - *self.value = self.min; - } - - let knob_size = Vec2::splat(self.size); - - let label_size = if let Some(label) = &self.label { - let font_id = egui::FontId::proportional(self.font_size); - let max_text = format!("{}: {}", label, (self.label_format)(self.max)); - ui.painter() - .layout(max_text, font_id, Color32::WHITE, f32::INFINITY) - .size() - } else { - Vec2::ZERO - }; - - let label_padding = 2.0; - - let adjusted_size = match self.label_position { - LabelPosition::Top | LabelPosition::Bottom => Vec2::new( - knob_size.x.max(label_size.x + label_padding * 2.0), - knob_size.y + label_size.y + label_padding * 2.0 + self.label_offset, - ), - LabelPosition::Left | LabelPosition::Right => Vec2::new( - knob_size.x + label_size.x + label_padding * 2.0 + self.label_offset, - knob_size.y.max(label_size.y + label_padding * 2.0), - ), - }; - - let (rect, mut response) = ui.allocate_exact_size(adjusted_size, Sense::drag()); - - if response.dragged() { - let delta = response.drag_delta().y; - let range = self.max - self.min; - let step = self.step.unwrap_or(range * self.drag_sensitivity); - let new_value = (*self.value - delta * step).clamp(self.min, self.max); - - *self.value = if let Some(step) = self.step { - let steps = ((new_value - self.min) / step).round(); - (self.min + steps * step).clamp(self.min, self.max) - } else { - new_value - }; - - if self.value.is_nan() { - *self.value = self.min; - } - - response.mark_changed(); - } - - let painter = ui.painter(); - let knob_rect = match self.label_position { - LabelPosition::Left => { - Rect::from_min_size(rect.right_top() + Vec2::new(-knob_size.x, 0.0), knob_size) - } - LabelPosition::Right => Rect::from_min_size(rect.left_top(), knob_size), - LabelPosition::Top => Rect::from_min_size( - rect.left_bottom() + Vec2::new((rect.width() - knob_size.x) / 2.0, -knob_size.y), - knob_size, - ), - LabelPosition::Bottom => Rect::from_min_size( - rect.left_top() + Vec2::new((rect.width() - knob_size.x) / 2.0, 0.0), - knob_size, - ), - }; - - let center = knob_rect.center(); - let radius = knob_size.x / 2.0; - let angle = self.compute_angle(); - let knob_hovered = response.hovered(); - let knob_color = if knob_hovered { - self.knob_color.linear_multiply(1.2) - } else { - self.knob_color - }; - - painter.circle_stroke(center, radius, Stroke::new(self.stroke_width, knob_color)); - - // Background arc (indicating full range) - if self.show_background_arc { - let arc_start = self.min_angle; - let arc_end = self.max_angle; - let segments = 64; - let arc_color = self.knob_color.gamma_multiply(0.5); // dimmed background arc - let arc_radius = radius * 0.8; - let mut points = Vec::with_capacity(segments + 1); - - for i in 0..=segments { - let t = i as f32 / segments as f32; - let angle = arc_start + (arc_end - arc_start) * t; - let pos = center + Vec2::angled(angle) * arc_radius; - points.push(pos); - } - - painter.add(egui::Shape::line( - points, - Stroke::new(self.stroke_width, arc_color), - )); - - // Filled part when background arc is enabled - if self.show_filled_segments { - let filled_segments = (segments as f32 - * ((*self.value - self.min) / (self.max - self.min)).clamp(0.0, 1.0)) - as usize; - - let mut fill_points = Vec::with_capacity(filled_segments + 1); - for i in 0..=filled_segments { - let t = i as f32 / segments as f32; - let angle = arc_start + (arc_end - arc_start) * t; - let pos = center + Vec2::angled(angle) * arc_radius; - fill_points.push(pos); - } - - painter.add(egui::Shape::line( - fill_points, - Stroke::new(self.stroke_width, self.line_color), - )); - } - } - - match self.style { - KnobStyle::Wiper => { - let pointer = center + Vec2::angled(angle) * (radius * 0.7); - painter.line_segment( - [center, pointer], - Stroke::new(self.stroke_width * 1.5, self.line_color), - ); - } - KnobStyle::Dot => { - let dot_pos = center + Vec2::angled(angle) * (radius * 0.7); - painter.circle_filled(dot_pos, self.stroke_width * 1.5, self.line_color); - } - } - - if let Some(label) = self.label { - let label_text = format!("{}: {}", label, (self.label_format)(*self.value)); - let font_id = egui::FontId::proportional(self.font_size); - - let label_padding = 2.0; - - let (label_pos, alignment) = match self.label_position { - LabelPosition::Top => ( - Vec2::new( - rect.center().x, - rect.min.y - self.label_offset + label_padding, - ), - Align2::CENTER_TOP, - ), - LabelPosition::Bottom => ( - Vec2::new(rect.center().x, rect.max.y + self.label_offset), - Align2::CENTER_BOTTOM, - ), - LabelPosition::Left => ( - Vec2::new(rect.min.x - self.label_offset, rect.center().y), - Align2::LEFT_CENTER, - ), - LabelPosition::Right => ( - Vec2::new(rect.max.x + self.label_offset, rect.center().y), - Align2::RIGHT_CENTER, - ), - }; - - ui.painter().text( - label_pos.to_pos2(), - alignment, - label_text, - font_id, - self.text_color, - ); - - if response.hovered() { - response - .clone() - .on_hover_text((self.label_format)(*self.value)); - } - } - - // Draw the bounding rect - //painter.rect_stroke(rect, 0.0, Stroke::new(1.0, Color32::RED)); - - response - } -} +pub use style::{KnobStyle, LabelPosition}; +pub use widget::Knob; diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..567e11f --- /dev/null +++ b/src/render.rs @@ -0,0 +1,196 @@ +use egui::{Align2, Color32, Painter, Pos2, Rect, Stroke, Ui, Vec2}; + +use crate::config::KnobConfig; +use crate::style::{KnobStyle, LabelPosition}; + +pub(crate) struct KnobRenderer<'a> { + config: &'a KnobConfig, + value: f32, + min: f32, + max: f32, +} + +impl<'a> KnobRenderer<'a> { + pub fn new(config: &'a KnobConfig, value: f32, min: f32, max: f32) -> Self { + Self { + config, + value, + min, + max, + } + } + + pub fn compute_angle(&self) -> f32 { + if self.min == self.max || self.value.is_nan() { + self.config.min_angle + } else { + self.config.min_angle + + (self.value - self.min) / (self.max - self.min) + * (self.config.max_angle - self.config.min_angle) + } + } + + pub fn render_knob(&self, painter: &Painter, center: Pos2, radius: f32, hovered: bool) { + let knob_color = if hovered { + self.config.colors.knob_color.linear_multiply(1.2) + } else { + self.config.colors.knob_color + }; + + painter.circle_stroke( + center, + radius, + Stroke::new(self.config.stroke_width, knob_color), + ); + + if self.config.show_background_arc { + self.render_background_arc(painter, center, radius); + } + + let angle = self.compute_angle(); + match self.config.style { + KnobStyle::Wiper => { + let pointer = center + Vec2::angled(angle) * (radius * 0.7); + painter.line_segment( + [center, pointer], + Stroke::new( + self.config.stroke_width * 1.5, + self.config.colors.line_color, + ), + ); + } + KnobStyle::Dot => { + let dot_pos = center + Vec2::angled(angle) * (radius * 0.7); + painter.circle_filled( + dot_pos, + self.config.stroke_width * 1.5, + self.config.colors.line_color, + ); + } + } + } + + fn render_background_arc(&self, painter: &Painter, center: Pos2, radius: f32) { + let arc_start = self.config.min_angle; + let arc_end = self.config.max_angle; + let segments = 64; + let arc_color = self.config.colors.knob_color.gamma_multiply(0.5); + let arc_radius = radius * 0.8; + + let mut points = Vec::with_capacity(segments + 1); + for i in 0..=segments { + let t = i as f32 / segments as f32; + let angle = arc_start + (arc_end - arc_start) * t; + let pos = center + Vec2::angled(angle) * arc_radius; + points.push(pos); + } + + painter.add(egui::Shape::line( + points, + Stroke::new(self.config.stroke_width, arc_color), + )); + + if self.config.show_filled_segments { + let filled_segments = (segments as f32 + * ((self.value - self.min) / (self.max - self.min)).clamp(0.0, 1.0)) + as usize; + + let mut fill_points = Vec::with_capacity(filled_segments + 1); + for i in 0..=filled_segments { + let t = i as f32 / segments as f32; + let angle = arc_start + (arc_end - arc_start) * t; + let pos = center + Vec2::angled(angle) * arc_radius; + fill_points.push(pos); + } + + painter.add(egui::Shape::line( + fill_points, + Stroke::new(self.config.stroke_width, self.config.colors.line_color), + )); + } + } + + pub fn render_label(&self, ui: &Ui, rect: Rect) { + if let Some(label) = &self.config.label { + let label_text = format!("{}: {}", label, (self.config.label_format)(self.value)); + let font_id = egui::FontId::proportional(self.config.font_size); + let label_padding = 2.0; + + let (label_pos, alignment) = match self.config.label_position { + LabelPosition::Top => ( + Vec2::new( + rect.center().x, + rect.min.y - self.config.label_offset + label_padding, + ), + Align2::CENTER_TOP, + ), + LabelPosition::Bottom => ( + Vec2::new(rect.center().x, rect.max.y + self.config.label_offset), + Align2::CENTER_BOTTOM, + ), + LabelPosition::Left => ( + Vec2::new(rect.min.x - self.config.label_offset, rect.center().y), + Align2::LEFT_CENTER, + ), + LabelPosition::Right => ( + Vec2::new(rect.max.x + self.config.label_offset, rect.center().y), + Align2::RIGHT_CENTER, + ), + }; + + ui.painter().text( + label_pos.to_pos2(), + alignment, + label_text, + font_id, + self.config.colors.text_color, + ); + } + } + + pub fn calculate_size(&self, ui: &Ui) -> Vec2 { + let knob_size = Vec2::splat(self.config.size); + + let label_size = if let Some(label) = &self.config.label { + let font_id = egui::FontId::proportional(self.config.font_size); + let max_text = format!("{}: {}", label, (self.config.label_format)(self.max)); + ui.painter() + .layout(max_text, font_id, Color32::WHITE, f32::INFINITY) + .size() + } else { + Vec2::ZERO + }; + + let label_padding = 2.0; + + match self.config.label_position { + LabelPosition::Top | LabelPosition::Bottom => Vec2::new( + knob_size.x.max(label_size.x + label_padding * 2.0), + knob_size.y + label_size.y + label_padding * 2.0 + self.config.label_offset, + ), + LabelPosition::Left | LabelPosition::Right => Vec2::new( + knob_size.x + label_size.x + label_padding * 2.0 + self.config.label_offset, + knob_size.y.max(label_size.y + label_padding * 2.0), + ), + } + } + + pub fn calculate_knob_rect(&self, rect: Rect) -> Rect { + let knob_size = Vec2::splat(self.config.size); + + match self.config.label_position { + LabelPosition::Left => { + Rect::from_min_size(rect.right_top() + Vec2::new(-knob_size.x, 0.0), knob_size) + } + LabelPosition::Right => Rect::from_min_size(rect.left_top(), knob_size), + LabelPosition::Top => Rect::from_min_size( + rect.left_bottom() + Vec2::new((rect.width() - knob_size.x) / 2.0, -knob_size.y), + knob_size, + ), + LabelPosition::Bottom => Rect::from_min_size( + rect.left_top() + Vec2::new((rect.width() - knob_size.x) / 2.0, 0.0), + knob_size, + ), + } + } +} diff --git a/src/style.rs b/src/style.rs new file mode 100644 index 0000000..e465e9c --- /dev/null +++ b/src/style.rs @@ -0,0 +1,44 @@ +use egui::Color32; + +/// Visual style of the knob indicator +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KnobStyle { + /// A line extending from the center to the edge + Wiper, + /// A dot on the edge of the knob + Dot, +} + +/// Position of the label relative to the knob +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LabelPosition { + /// Label appears above the knob + Top, + /// Label appears below the knob + Bottom, + /// Label appears to the left of the knob + Left, + /// Label appears to the right of the knob + Right, +} + +/// Color configuration for the knob widget +#[derive(Debug, Clone, Copy)] +pub struct KnobColors { + /// Color of the knob's outline + pub knob_color: Color32, + /// Color of the indicator (wiper or dot) + pub line_color: Color32, + /// Color of the label text + pub text_color: Color32, +} + +impl Default for KnobColors { + fn default() -> Self { + Self { + knob_color: Color32::GRAY, + line_color: Color32::GRAY, + text_color: Color32::WHITE, + } + } +} diff --git a/src/widget.rs b/src/widget.rs new file mode 100644 index 0000000..370cd67 --- /dev/null +++ b/src/widget.rs @@ -0,0 +1,211 @@ +use egui::{Color32, Response, Sense, Ui, Widget}; + +use crate::config::KnobConfig; +use crate::render::KnobRenderer; +use crate::style::{KnobStyle, LabelPosition}; + +pub struct Knob<'a> { + pub(crate) value: &'a mut f32, + pub(crate) min: f32, + pub(crate) max: f32, + pub(crate) config: KnobConfig, +} + +impl<'a> Knob<'a> { + /// Creates a new knob widget + /// + /// # Arguments + /// * `value` - Mutable reference to the value controlled by the knob + /// * `min` - Minimum value + /// * `max` - Maximum value + /// * `style` - Visual style of the knob indicator + pub fn new(value: &'a mut f32, min: f32, max: f32, style: KnobStyle) -> Self { + Self { + value, + min, + max, + config: KnobConfig::new(style), + } + } + + /// Sets the angular sweep range of the knob + /// + /// This controls where the knob starts and how far it can rotate. By default, + /// knobs start at the left (180°) and sweep 270° clockwise to bottom. + /// + /// # Arguments + /// * `start_angle_normalized` - Starting position as fraction of full circle: + /// - `0.0` = bottom (6 o'clock) + /// - `0.25` = left (9 o'clock) + /// - `0.5` = top (12 o'clock) + /// - `0.75` = right (3 o'clock) + /// * `range` - How far the knob can sweep as fraction of full circle: + /// - `0.25` = quarter turn (90°) + /// - `0.5` = half turn (180°) + /// - `0.75` = three-quarter turn (270°) + /// - `1.0` = full turn (360°) + /// - Values > 1.0 create multi-turn knobs + /// - Negative values are clamped to 0.0 + /// + /// Note: the start angle is offset by PI/2 so that `0.0` is at the bottom (6 o'clock) + pub fn with_sweep_range(mut self, start_angle_normalized: f32, range: f32) -> Self { + if start_angle_normalized.is_nan() || range.is_nan() { + return self; + } + + self.config.min_angle = + start_angle_normalized.rem_euclid(1.0) * std::f32::consts::TAU + std::f32::consts::PI / 2.0; + self.config.max_angle = self.config.min_angle + range.max(0.0) * std::f32::consts::TAU; + self + } + + /// Sets the size of the knob + pub fn with_size(mut self, size: f32) -> Self { + self.config.size = size; + self + } + + /// Sets the font size for the label + pub fn with_font_size(mut self, size: f32) -> Self { + self.config.font_size = size; + self + } + + /// Sets the stroke width for the knob's outline and indicator + pub fn with_stroke_width(mut self, width: f32) -> Self { + self.config.stroke_width = width; + self + } + + /// Sets the colors for different parts of the knob + /// + /// # Arguments + /// * `knob_color` - Color of the knob's outline + /// * `line_color` - Color of the indicator + /// * `text_color` - Color of the label text + pub fn with_colors( + mut self, + knob_color: Color32, + line_color: Color32, + text_color: Color32, + ) -> Self { + self.config.colors.knob_color = knob_color; + self.config.colors.line_color = line_color; + self.config.colors.text_color = text_color; + self + } + + /// Adds a label to the knob + /// + /// # Arguments + /// * `label` - Text to display + /// * `position` - Position of the label relative to the knob + pub fn with_label(mut self, label: impl Into, position: LabelPosition) -> Self { + self.config.label = Some(label.into()); + self.config.label_position = position; + self + } + + /// Sets the spacing between the knob and its label + pub fn with_label_offset(mut self, offset: f32) -> Self { + self.config.label_offset = offset; + self + } + + /// Sets a custom format function for displaying the value + /// + /// # Example + /// ```no_run + /// use egui_knob::{Knob, KnobStyle}; + /// ui.add( + /// Knob::new(&mut value, 0.0, 1.0, KnobStyle::Wiper) + /// .with_label_format(|v| format!("{:.1}%", v * 100.0)) + /// ); + /// ``` + pub fn with_label_format(mut self, format: impl Fn(f32) -> String + 'static) -> Self { + self.config.label_format = Box::new(format); + self + } + + /// Sets the step size for value changes + pub fn with_step(mut self, step: Option) -> Self { + self.config.step = step; + self + } + + /// Controls whether to show the background arc indicating the full range + pub fn with_background_arc(mut self, enabled: bool) -> Self { + self.config.show_background_arc = enabled; + self + } + + /// Controls whether to show the filled segment on the background arc + /// + /// When enabled (and background arc is visible), displays a colored segment + /// from the minimum position to the current value position. + pub fn with_show_filled_segments(mut self, enabled: bool) -> Self { + self.config.show_filled_segments = enabled; + self + } + + /// Sets the drag sensitivity for mouse interactions + /// + /// Default is 0.005. + pub fn with_drag_sensitivity(mut self, sensitivity: f32) -> Self { + self.config.drag_sensitivity = sensitivity; + self + } + +} + +impl Widget for Knob<'_> { + fn ui(self, ui: &mut Ui) -> Response { + if self.value.is_nan() { + *self.value = self.min; + } + + let current_value = *self.value; + let renderer = KnobRenderer::new(&self.config, current_value, self.min, self.max); + let adjusted_size = renderer.calculate_size(ui); + + let (rect, response) = ui.allocate_exact_size(adjusted_size, Sense::drag()); + + let mut response = response; + if response.dragged() { + let delta = response.drag_delta().y; + let range = self.max - self.min; + let step = self.config.step.unwrap_or(range * self.config.drag_sensitivity); + let new_value = (*self.value - delta * step).clamp(self.min, self.max); + + *self.value = if let Some(step) = self.config.step { + let steps = ((new_value - self.min) / step).round(); + (self.min + steps * step).clamp(self.min, self.max) + } else { + new_value + }; + + if self.value.is_nan() { + *self.value = self.min; + } + + response.mark_changed(); + } + + let knob_rect = renderer.calculate_knob_rect(rect); + let center = knob_rect.center(); + let radius = self.config.size / 2.0; + + let updated_value = *self.value; + let updated_renderer = KnobRenderer::new(&self.config, updated_value, self.min, self.max); + updated_renderer.render_knob(ui.painter(), center, radius, response.hovered()); + updated_renderer.render_label(ui, rect); + + if self.config.label.is_some() && response.hovered() { + response + .clone() + .on_hover_text((self.config.label_format)(*self.value)); + } + + response + } +}