Files
hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/widgets/plain_slider.rs
T

335 lines
11 KiB
Rust
Raw Normal View History

//! 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, 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<Message> {
/// 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<f32>,
/// 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<dyn Fn(f32) -> Message>,
}
impl<Message> std::fmt::Debug for PlainSlider<Message> {
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<dyn Fn(f32) -> Message>")
.finish()
}
}
impl<Message> PlainSlider<Message> {
/// 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<String>,
value: f32,
range: std::ops::RangeInclusive<f32>,
step: f32,
suffix: impl Into<String>,
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<f32> {
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<Message> Component<Message, Theme, Renderer> for PlainSlider<Message>
where
Message: Clone,
{
type State = State;
type Event = Event;
fn update(&mut self, state: &mut State, event: Event) -> Option<Message> {
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);
let slider_control = slider(self.range.clone(), self.value, Event::SliderChanged)
.step(self.step)
.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);
column![
row![
text(&self.label).size(12).width(Length::Shrink),
editor,
]
.spacing(4)
.align_y(iced::Alignment::Center),
slider_control,
]
.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)
.align_y(iced::Alignment::Center),
slider_control,
]
.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<Message>(
label: impl Into<String>,
value: f32,
range: std::ops::RangeInclusive<f32>,
step: f32,
suffix: impl Into<String>,
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);
}
}