3659b886ff
- Improved layout and styling in filter_params.rs for better visual consistency. - Enhanced filters.rs to ensure scrollable parameters fill the available width. - Added a new Custom Shapes panel to display user-defined SVG shapes. - Updated menus.rs to include a new Custom Shapes menu item and adjusted related logic. - Modified properties.rs to support additional spray tool properties (particle size and density). - Adjusted title_bar.rs for improved menu button dimensions and layout. - Enhanced toolbar.rs to include spray tool options in the toolbar. - Fixed color persistence issues in settings.rs to ensure colors are saved and loaded correctly. - Updated plain_slider.rs to ensure sliders utilize the full width of their containers. - Created a visual polish plan to address various UI/UX issues and improve overall usability.
343 lines
12 KiB
Rust
343 lines
12 KiB
Rust
//! 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<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);
|
|
|
|
// 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<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);
|
|
}
|
|
}
|