//! A keyboard-editable numeric slider widget for iced. //! //! Mirrors the egui `PlainSlider` used in the egui GUI so the iced //! Properties panel supports direct value entry from the keyboard. //! The widget is implemented as an iced `Component`, which keeps the //! transient edit state inside the widget instead of in the application //! state. This trait is deprecated in iced 0.13, but it is still the //! simplest way to bundle local state with a reusable widget. #![allow(deprecated)] use iced::widget::{column, component, container, mouse_area, row, slider, text, text_input, Component}; use iced::{Element, Length, Renderer, Theme}; use std::sync::Arc; /// Internal state for a `PlainSlider` instance. #[derive(Debug, Default, Clone)] pub(crate) struct State { /// Whether the user is currently typing a value. editing: bool, /// Current contents of the text input while editing. buffer: String, } /// Internal events produced by the widget UI. #[derive(Debug, Clone)] pub(crate) enum Event { /// The slider thumb was dragged to a new value. SliderChanged(f32), /// The value label was clicked; switch to text-input mode. StartEdit, /// The text input contents changed. InputChanged(String), /// The user pressed Enter or otherwise submitted the typed value. Submit, } /// A numeric slider with an editable value label. /// /// The value label displays the current value with a suffix such as `%` /// or `px`. Clicking the label replaces it with a text input so the user /// can type an exact value. #[derive(Clone)] pub(crate) struct PlainSlider { /// Human-readable label shown to the left of the value. label: String, /// Current value. The parent application owns the authoritative value /// and passes it back on each frame. value: f32, /// Inclusive numeric range the value is allowed to occupy. range: std::ops::RangeInclusive, /// Step size used by the slider thumb. step: f32, /// Suffix shown after the value (e.g. "%", "px"). suffix: String, /// Number of decimal places to display. decimals: usize, /// Width of the value label / text input area. input_width: f32, /// Callback invoked whenever the value changes, either by dragging /// the slider or by submitting the text input. #[allow(clippy::type_complexity)] on_change: Arc Message>, } impl std::fmt::Debug for PlainSlider { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PlainSlider") .field("label", &self.label) .field("value", &self.value) .field("range", &self.range) .field("step", &self.step) .field("suffix", &self.suffix) .field("decimals", &self.decimals) .field("input_width", &self.input_width) .field("on_change", &"Arc Message>") .finish() } } impl PlainSlider { /// Creates a new editable slider. /// /// # Arguments /// * `label` - short label shown next to the value. /// * `value` - current numeric value. /// * `range` - inclusive allowed range. /// * `step` - slider step. /// * `suffix` - unit suffix (e.g. "%", "px"). /// * `decimals` - number of decimals to display. /// * `on_change` - callback producing a message for the parent. pub fn new( label: impl Into, value: f32, range: std::ops::RangeInclusive, step: f32, suffix: impl Into, decimals: usize, on_change: impl Fn(f32) -> Message + 'static, ) -> Self { Self { label: label.into(), value, range, step, suffix: suffix.into(), decimals, input_width: 48.0, on_change: Arc::new(on_change), } } /// Sets the width of the editable value area. #[allow(dead_code)] pub fn input_width(mut self, width: f32) -> Self { self.input_width = width; self } /// Formats a numeric value for display, including the suffix. /// /// Percentage values are stored normalized (0..1) and displayed /// multiplied by 100. Other units are displayed as-is. fn format_value(&self, value: f32) -> String { if self.suffix == "%" { format!("{:.*}{}", self.decimals, value * 100.0, self.suffix) } else if self.suffix.is_empty() { format!("{:.*}", self.decimals, value) } else { format!("{:.*} {}", self.decimals, value, self.suffix) } } /// Parses a raw string back into a numeric value, taking `%` into account. fn parse_value(&self, raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() { return None; } let numeric = trimmed .trim_end_matches(&self.suffix) .trim() .replace(',', "."); let parsed: f32 = numeric.parse().ok()?; let value = if self.suffix == "%" { parsed / 100.0 } else { parsed }; Some(value.clamp(*self.range.start(), *self.range.end())) } } impl Component for PlainSlider where Message: Clone, { type State = State; type Event = Event; fn update(&mut self, state: &mut State, event: Event) -> Option { match event { Event::SliderChanged(new_value) => { // Update the local value so the displayed text follows the thumb. self.value = new_value.clamp(*self.range.start(), *self.range.end()); Some((self.on_change)(self.value)) } Event::StartEdit => { state.editing = true; state.buffer = self.format_value(self.value); None } Event::InputChanged(new_value) => { state.buffer = new_value; None } Event::Submit => { if let Some(v) = self.parse_value(&state.buffer) { self.value = v; state.editing = false; state.buffer.clear(); Some((self.on_change)(v)) } else { state.editing = false; state.buffer.clear(); None } } } } fn view(&self, state: &State) -> Element<'_, Event> { let value_text = self.format_value(self.value); // The slider is wrapped in a fill container so the thumb track uses the // full available width from the parent panel, not just its intrinsic size. let slider_control = container( slider(self.range.clone(), self.value, Event::SliderChanged) .step(self.step) .width(Length::Fill), ) .width(Length::Fill); if state.editing { let input = text_input("", &state.buffer) .width(Length::Fixed(self.input_width)) .size(12) .on_input(Event::InputChanged) .on_submit(Event::Submit); let editor = row![input] .align_y(iced::Alignment::Center) .spacing(4) .width(Length::Fill); column![ row![ text(&self.label).size(12).width(Length::Shrink), editor, ] .spacing(4) .width(Length::Fill) .align_y(iced::Alignment::Center), slider_control.width(Length::Fill), ] .spacing(2) .into() } else { let value_label = mouse_area( text(value_text) .size(12) .width(Length::Fixed(self.input_width)) .align_x(iced::alignment::Horizontal::Right), ) .on_press(Event::StartEdit); column![ row![ text(&self.label).size(12).width(Length::Shrink), value_label, ] .spacing(4) .width(Length::Fill) .align_y(iced::Alignment::Center), slider_control.width(Length::Fill), ] .spacing(2) .into() } } } /// Convenience constructor that wraps `PlainSlider` in a lazy component element. /// /// Use this from panel code in place of the standard `slider` widget. pub fn plain_slider( label: impl Into, value: f32, range: std::ops::RangeInclusive, step: f32, suffix: impl Into, decimals: usize, on_change: impl Fn(f32) -> Message + 'static, ) -> Element<'static, Message> where Message: 'static + Clone, { component(PlainSlider::new(label, value, range, step, suffix, decimals, on_change)) } #[cfg(test)] mod tests { use super::PlainSlider; use iced::widget::Component; fn dummy(_: f32) -> () {} #[test] fn formats_percentage_without_decimals() { let slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy); assert_eq!(slider.format_value(0.5), "50%"); assert_eq!(slider.format_value(1.0), "100%"); assert_eq!(slider.format_value(0.0), "0%"); } #[test] fn formats_pxx_with_decimals() { let slider = PlainSlider::new("Stroke", 37.0, 0.0..=50.0, 0.5, "px", 1, dummy); assert_eq!(slider.format_value(37.0), "37.0 px"); } #[test] fn parses_percentage_input() { let slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy); assert!((slider.parse_value("75%").unwrap() - 0.75).abs() < 1e-6); assert!((slider.parse_value("75").unwrap() - 0.75).abs() < 1e-6); assert_eq!(slider.parse_value(""), None); } #[test] fn parses_px_input() { let slider = PlainSlider::new("Stroke", 10.0, 0.0..=50.0, 0.5, "px", 1, dummy); assert!((slider.parse_value("25 px").unwrap() - 25.0).abs() < 1e-6); assert!((slider.parse_value("25").unwrap() - 25.0).abs() < 1e-6); } #[test] fn clamps_out_of_range_input() { let slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy); assert_eq!(slider.parse_value("150"), Some(1.0)); assert_eq!(slider.parse_value("-10"), Some(0.0)); } #[test] fn update_emits_on_slider_drag() { let mut slider = PlainSlider::new("Size", 10.0, 1.0..=200.0, 1.0, "px", 0, dummy); let mut state = super::State::default(); let message = slider.update(&mut state, super::Event::SliderChanged(42.0)); assert_eq!(message, Some(())); assert!((slider.value - 42.0).abs() < 1e-6); } #[test] fn update_commits_valid_text_input() { let mut slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy); let mut state = super::State::default(); slider.update(&mut state, super::Event::StartEdit); slider.update(&mut state, super::Event::InputChanged("80".to_string())); let message = slider.update(&mut state, super::Event::Submit); assert!((slider.value - 0.8).abs() < 1e-6); assert_eq!(message, Some(())); assert!(!state.editing); } #[test] fn update_ignores_invalid_text_input() { let mut slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy); let mut state = super::State::default(); slider.update(&mut state, super::Event::StartEdit); slider.update(&mut state, super::Event::InputChanged("abc".to_string())); let message = slider.update(&mut state, super::Event::Submit); assert!((slider.value - 0.5).abs() < 1e-6); assert_eq!(message, None); assert!(!state.editing); } }