2026-07-21 04:25:23 +03:00
|
|
|
//! Theme-aware progress slider with hover-activated numeric entry.
|
2026-07-19 00:55:40 +03:00
|
|
|
//!
|
2026-07-21 04:25:23 +03:00
|
|
|
//! **Purpose:** Provides one compact numeric control for every Iced panel, matching the egui
|
|
|
|
|
//! `PlainSlider` interaction model without relying on the deprecated Iced `Component` API.
|
|
|
|
|
//! **Logic & Workflow:** An Iced Canvas retains transient drag/edit state, maps pointer positions
|
|
|
|
|
//! to stepped values, captures numeric keyboard input while hovered, and draws a recessed track,
|
|
|
|
|
//! gradient value fill, spinner arrows, centered text, and hover elevation.
|
2026-07-21 17:40:41 +03:00
|
|
|
//! **Interaction model:**
|
|
|
|
|
//! - Click and drag anywhere on the track to adjust the value.
|
|
|
|
|
//! - Type any numeric character while hovering to start direct text input.
|
|
|
|
|
//! - Press `Enter` to commit the typed value, `Escape` to cancel.
|
|
|
|
|
//! - Click the spinner arrows for single-step increments/decrements.
|
2026-07-21 04:25:23 +03:00
|
|
|
//! **Side Effects / Dependencies:** Emits caller-provided messages; all state remains local to the
|
|
|
|
|
//! widget tree and all colors derive from `ThemeColors`.
|
2026-07-19 00:55:40 +03:00
|
|
|
|
2026-07-21 04:25:23 +03:00
|
|
|
use crate::theme::ThemeColors;
|
|
|
|
|
use iced::keyboard::{self, key::Named, Key};
|
|
|
|
|
use iced::mouse;
|
|
|
|
|
use iced::widget::canvas::{self, Canvas, Frame, Path, Stroke};
|
|
|
|
|
use iced::{alignment, border, Element, Length, Pixels, Point, Rectangle, Size};
|
2026-07-19 00:55:40 +03:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
2026-07-21 04:25:23 +03:00
|
|
|
const CONTROL_HEIGHT: f32 = 22.0;
|
|
|
|
|
const SPINNER_WIDTH: f32 = 24.0;
|
|
|
|
|
|
|
|
|
|
/// Persistent interaction state for one slider instance.
|
|
|
|
|
#[derive(Debug, Default)]
|
2026-07-19 00:55:40 +03:00
|
|
|
pub(crate) struct State {
|
2026-07-21 04:25:23 +03:00
|
|
|
dragging: bool,
|
2026-07-19 00:55:40 +03:00
|
|
|
editing: bool,
|
|
|
|
|
buffer: String,
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 04:25:23 +03:00
|
|
|
/// Canvas program describing one plain slider.
|
2026-07-19 00:55:40 +03:00
|
|
|
#[derive(Clone)]
|
2026-07-21 04:25:23 +03:00
|
|
|
struct PlainSlider<Message> {
|
2026-07-19 00:55:40 +03:00
|
|
|
label: String,
|
|
|
|
|
value: f32,
|
|
|
|
|
range: std::ops::RangeInclusive<f32>,
|
|
|
|
|
step: f32,
|
|
|
|
|
suffix: String,
|
|
|
|
|
decimals: usize,
|
2026-07-21 04:25:23 +03:00
|
|
|
colors: ThemeColors,
|
2026-07-19 00:55:40 +03:00
|
|
|
on_change: Arc<dyn Fn(f32) -> Message>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<Message> std::fmt::Debug for PlainSlider<Message> {
|
2026-07-21 04:25:23 +03:00
|
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
|
|
|
formatter
|
|
|
|
|
.debug_struct("PlainSlider")
|
2026-07-19 00:55:40 +03:00
|
|
|
.field("label", &self.label)
|
|
|
|
|
.field("value", &self.value)
|
|
|
|
|
.field("range", &self.range)
|
|
|
|
|
.field("step", &self.step)
|
|
|
|
|
.field("suffix", &self.suffix)
|
|
|
|
|
.field("decimals", &self.decimals)
|
2026-07-21 04:25:23 +03:00
|
|
|
.finish_non_exhaustive()
|
2026-07-19 00:55:40 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<Message> PlainSlider<Message> {
|
2026-07-21 04:25:23 +03:00
|
|
|
/// Formats the current value for centered display.
|
2026-07-19 00:55:40 +03:00
|
|
|
fn format_value(&self, value: f32) -> String {
|
2026-07-21 04:25:23 +03:00
|
|
|
let displayed = if self.suffix == "%" {
|
|
|
|
|
value * 100.0
|
2026-07-19 00:55:40 +03:00
|
|
|
} else {
|
2026-07-21 04:25:23 +03:00
|
|
|
value
|
|
|
|
|
};
|
|
|
|
|
let value = format!("{:.*}", self.decimals, displayed);
|
|
|
|
|
if self.suffix.is_empty() {
|
|
|
|
|
format!("{}: {}", self.label, value)
|
|
|
|
|
} else if self.suffix == "%" {
|
|
|
|
|
format!("{}: {}%", self.label, value)
|
|
|
|
|
} else {
|
|
|
|
|
format!("{}: {} {}", self.label, value, self.suffix)
|
2026-07-19 00:55:40 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 04:25:23 +03:00
|
|
|
/// Parses an edit buffer, normalizes percentages, and clamps to the slider range.
|
2026-07-19 00:55:40 +03:00
|
|
|
fn parse_value(&self, raw: &str) -> Option<f32> {
|
2026-07-21 04:25:23 +03:00
|
|
|
let numeric = raw
|
|
|
|
|
.trim()
|
2026-07-19 00:55:40 +03:00
|
|
|
.trim_end_matches(&self.suffix)
|
|
|
|
|
.trim()
|
|
|
|
|
.replace(',', ".");
|
2026-07-21 04:25:23 +03:00
|
|
|
let parsed = numeric.parse::<f32>().ok()?;
|
|
|
|
|
let value = if self.suffix == "%" {
|
|
|
|
|
parsed / 100.0
|
2026-07-19 00:55:40 +03:00
|
|
|
} else {
|
2026-07-21 04:25:23 +03:00
|
|
|
parsed
|
|
|
|
|
};
|
|
|
|
|
Some(self.snap(value))
|
|
|
|
|
}
|
2026-07-19 00:55:40 +03:00
|
|
|
|
2026-07-21 04:25:23 +03:00
|
|
|
/// Snaps a value to the configured step relative to the range minimum.
|
|
|
|
|
fn snap(&self, value: f32) -> f32 {
|
|
|
|
|
let min = *self.range.start();
|
|
|
|
|
let max = *self.range.end();
|
|
|
|
|
if self.step > f32::EPSILON {
|
|
|
|
|
(min + ((value - min) / self.step).round() * self.step).clamp(min, max)
|
|
|
|
|
} else {
|
|
|
|
|
value.clamp(min, max)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Maps a horizontal cursor coordinate to a stepped value.
|
|
|
|
|
fn value_at(&self, x: f32, width: f32) -> f32 {
|
|
|
|
|
let track_width = (width - SPINNER_WIDTH).max(1.0);
|
|
|
|
|
let fraction = (x / track_width).clamp(0.0, 1.0);
|
|
|
|
|
self.snap(*self.range.start() + (*self.range.end() - *self.range.start()) * fraction)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 19:11:01 +03:00
|
|
|
/// Computes the slider value for the current cursor position while dragging.
|
|
|
|
|
///
|
|
|
|
|
/// **Purpose:** Allow an active drag to continue updating even when the cursor moves
|
|
|
|
|
/// slightly outside the widget bounds, so 0 % and 100 % can always be reached.
|
|
|
|
|
/// **Logic & Workflow:** If the cursor is inside the widget bounds, use its local X.
|
|
|
|
|
/// Otherwise fall back to the global cursor position and convert it to widget-local
|
|
|
|
|
/// X by subtracting `bounds.x`. Clamp the resulting X to the draggable track width.
|
|
|
|
|
/// **Arguments:** `cursor` is the current mouse cursor; `bounds` is the widget rectangle.
|
|
|
|
|
/// **Returns:** The stepped value at the clamped cursor position.
|
|
|
|
|
fn drag_value(&self, cursor: mouse::Cursor, bounds: Rectangle) -> f32 {
|
|
|
|
|
let track_width = (bounds.width - SPINNER_WIDTH).max(1.0);
|
|
|
|
|
let x = match cursor.position_in(bounds) {
|
|
|
|
|
Some(local) => local.x,
|
|
|
|
|
None => cursor.position().map_or(0.0, |p| p.x - bounds.x),
|
|
|
|
|
}
|
|
|
|
|
.clamp(0.0, track_width);
|
|
|
|
|
self.value_at(x, bounds.width)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 04:25:23 +03:00
|
|
|
/// Returns the normalized fill fraction for the current value.
|
|
|
|
|
fn fraction(&self) -> f32 {
|
|
|
|
|
let span = *self.range.end() - *self.range.start();
|
|
|
|
|
if span.abs() <= f32::EPSILON {
|
|
|
|
|
0.0
|
|
|
|
|
} else {
|
|
|
|
|
((self.value - *self.range.start()) / span).clamp(0.0, 1.0)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Starts direct numeric editing with one typed character.
|
|
|
|
|
fn begin_typing(state: &mut State, text: &str) -> bool {
|
|
|
|
|
if is_numeric_fragment(text) {
|
|
|
|
|
state.editing = true;
|
|
|
|
|
state.buffer.clear();
|
|
|
|
|
state.buffer.push_str(text);
|
|
|
|
|
true
|
|
|
|
|
} else {
|
|
|
|
|
false
|
2026-07-19 00:55:40 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 04:25:23 +03:00
|
|
|
impl<Message> canvas::Program<Message> for PlainSlider<Message> {
|
|
|
|
|
type State = State;
|
|
|
|
|
|
|
|
|
|
fn draw(
|
|
|
|
|
&self,
|
|
|
|
|
state: &Self::State,
|
|
|
|
|
renderer: &iced::Renderer,
|
|
|
|
|
_theme: &iced::Theme,
|
|
|
|
|
bounds: Rectangle,
|
|
|
|
|
cursor: mouse::Cursor,
|
|
|
|
|
) -> Vec<canvas::Geometry> {
|
|
|
|
|
let mut frame = Frame::new(renderer, bounds.size());
|
|
|
|
|
let hovered = cursor.is_over(bounds);
|
|
|
|
|
let radius = border::Radius::from(4.0);
|
|
|
|
|
let outer = Path::rounded_rectangle(Point::ORIGIN, bounds.size(), radius);
|
|
|
|
|
|
|
|
|
|
if hovered {
|
|
|
|
|
for expansion in [3.0_f32, 2.0, 1.0] {
|
|
|
|
|
let mut shadow = self.colors.accent;
|
|
|
|
|
shadow.a = 0.035 * (4.0 - expansion);
|
|
|
|
|
let path = Path::rounded_rectangle(
|
|
|
|
|
Point::new(-expansion, -expansion),
|
|
|
|
|
Size::new(
|
|
|
|
|
bounds.width + expansion * 2.0,
|
|
|
|
|
bounds.height + expansion * 2.0,
|
|
|
|
|
),
|
|
|
|
|
border::Radius::from(4.0 + expansion),
|
|
|
|
|
);
|
|
|
|
|
frame.stroke(&path, Stroke::default().with_color(shadow).with_width(1.0));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
frame.fill(&outer, self.colors.recessed());
|
|
|
|
|
|
|
|
|
|
let track_width = (bounds.width - SPINNER_WIDTH).max(0.0);
|
|
|
|
|
let fill_width = track_width * self.fraction();
|
|
|
|
|
if fill_width > 0.0 {
|
2026-07-24 19:11:01 +03:00
|
|
|
let mut fill = self.colors.accent;
|
|
|
|
|
fill.a = if self.colors.is_light { 0.30 } else { 0.25 };
|
|
|
|
|
let fill_rect = Path::rounded_rectangle(Point::new(0.0, 0.0), Size::new(fill_width, bounds.height), radius);
|
|
|
|
|
frame.fill(&fill_rect, fill);
|
2026-07-21 04:25:23 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
frame.stroke(
|
|
|
|
|
&outer,
|
|
|
|
|
Stroke::default()
|
|
|
|
|
.with_color(if hovered {
|
|
|
|
|
self.colors.accent
|
|
|
|
|
} else {
|
|
|
|
|
self.colors.border_high
|
|
|
|
|
})
|
|
|
|
|
.with_width(1.0),
|
|
|
|
|
);
|
|
|
|
|
frame.stroke(
|
|
|
|
|
&Path::rectangle(
|
|
|
|
|
Point::new(track_width, 1.0),
|
|
|
|
|
Size::new(1.0, (bounds.height - 2.0).max(0.0)),
|
|
|
|
|
),
|
|
|
|
|
Stroke::default()
|
|
|
|
|
.with_color(self.colors.border_low)
|
|
|
|
|
.with_width(1.0),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let center_x = track_width / 2.0;
|
|
|
|
|
let display = if state.editing {
|
|
|
|
|
format!("{}|", state.buffer)
|
|
|
|
|
} else {
|
|
|
|
|
self.format_value(self.value)
|
|
|
|
|
};
|
|
|
|
|
frame.fill_text(canvas::Text {
|
|
|
|
|
content: display,
|
|
|
|
|
position: Point::new(center_x, bounds.height / 2.0),
|
|
|
|
|
color: self.colors.text_primary,
|
|
|
|
|
size: Pixels(12.0),
|
|
|
|
|
horizontal_alignment: alignment::Horizontal::Center,
|
|
|
|
|
vertical_alignment: alignment::Vertical::Center,
|
|
|
|
|
..Default::default()
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let spinner_x = track_width + SPINNER_WIDTH / 2.0;
|
|
|
|
|
for (center_y, upward) in [(6.5, true), (15.5, false)] {
|
|
|
|
|
let path = Path::new(|builder| {
|
|
|
|
|
if upward {
|
|
|
|
|
builder.move_to(Point::new(spinner_x, center_y - 2.5));
|
|
|
|
|
builder.line_to(Point::new(spinner_x - 3.0, center_y + 2.0));
|
|
|
|
|
builder.line_to(Point::new(spinner_x + 3.0, center_y + 2.0));
|
|
|
|
|
} else {
|
|
|
|
|
builder.move_to(Point::new(spinner_x, center_y + 2.5));
|
|
|
|
|
builder.line_to(Point::new(spinner_x - 3.0, center_y - 2.0));
|
|
|
|
|
builder.line_to(Point::new(spinner_x + 3.0, center_y - 2.0));
|
|
|
|
|
}
|
|
|
|
|
builder.close();
|
|
|
|
|
});
|
|
|
|
|
frame.fill(&path, self.colors.text_secondary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
vec![frame.into_geometry()]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn update(
|
|
|
|
|
&self,
|
|
|
|
|
state: &mut Self::State,
|
|
|
|
|
event: canvas::Event,
|
|
|
|
|
bounds: Rectangle,
|
|
|
|
|
cursor: mouse::Cursor,
|
|
|
|
|
) -> (canvas::event::Status, Option<Message>) {
|
|
|
|
|
let local = cursor.position_in(bounds);
|
|
|
|
|
|
|
|
|
|
match event {
|
|
|
|
|
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
|
|
|
|
let Some(position) = local else {
|
|
|
|
|
if state.editing {
|
|
|
|
|
state.editing = false;
|
|
|
|
|
state.buffer.clear();
|
|
|
|
|
}
|
|
|
|
|
return (canvas::event::Status::Ignored, None);
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-21 17:40:41 +03:00
|
|
|
// Spinner arrows remain step controls.
|
2026-07-21 04:25:23 +03:00
|
|
|
if position.x >= bounds.width - SPINNER_WIDTH {
|
|
|
|
|
let direction = if position.y < bounds.height / 2.0 {
|
|
|
|
|
1.0
|
|
|
|
|
} else {
|
|
|
|
|
-1.0
|
|
|
|
|
};
|
|
|
|
|
let value = self.snap(self.value + self.step.max(f32::EPSILON) * direction);
|
|
|
|
|
return (
|
|
|
|
|
canvas::event::Status::Captured,
|
|
|
|
|
Some((self.on_change)(value)),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 17:40:41 +03:00
|
|
|
// Anywhere else on the track starts a drag; explicit edit mode
|
|
|
|
|
// is no longer required before typing.
|
|
|
|
|
state.editing = false;
|
|
|
|
|
state.buffer.clear();
|
2026-07-21 04:25:23 +03:00
|
|
|
state.dragging = true;
|
|
|
|
|
let value = self.value_at(position.x, bounds.width);
|
|
|
|
|
(
|
|
|
|
|
canvas::event::Status::Captured,
|
|
|
|
|
Some((self.on_change)(value)),
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-07-21 17:40:41 +03:00
|
|
|
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
|
|
|
|
|
// Right click outside edit mode is ignored; while editing it cancels.
|
|
|
|
|
if state.editing {
|
|
|
|
|
state.editing = false;
|
|
|
|
|
state.buffer.clear();
|
|
|
|
|
return (canvas::event::Status::Captured, None);
|
|
|
|
|
}
|
|
|
|
|
(canvas::event::Status::Ignored, None)
|
|
|
|
|
}
|
2026-07-21 04:25:23 +03:00
|
|
|
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
|
2026-07-24 19:11:01 +03:00
|
|
|
let value = self.drag_value(cursor, bounds);
|
|
|
|
|
return (
|
|
|
|
|
canvas::event::Status::Captured,
|
|
|
|
|
Some((self.on_change)(value)),
|
|
|
|
|
);
|
2026-07-21 04:25:23 +03:00
|
|
|
}
|
|
|
|
|
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
|
|
|
|
if state.dragging {
|
|
|
|
|
state.dragging = false;
|
|
|
|
|
return (canvas::event::Status::Captured, None);
|
|
|
|
|
}
|
|
|
|
|
(canvas::event::Status::Ignored, None)
|
|
|
|
|
}
|
|
|
|
|
canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, text, .. })
|
|
|
|
|
if local.is_some() || state.editing =>
|
|
|
|
|
{
|
|
|
|
|
if state.editing {
|
|
|
|
|
match key {
|
|
|
|
|
Key::Named(Named::Enter) => {
|
|
|
|
|
let value = self.parse_value(&state.buffer);
|
|
|
|
|
state.editing = false;
|
|
|
|
|
state.buffer.clear();
|
|
|
|
|
return (
|
|
|
|
|
canvas::event::Status::Captured,
|
|
|
|
|
value.map(|value| (self.on_change)(value)),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Key::Named(Named::Escape) => {
|
|
|
|
|
state.editing = false;
|
|
|
|
|
state.buffer.clear();
|
|
|
|
|
return (canvas::event::Status::Captured, None);
|
|
|
|
|
}
|
|
|
|
|
Key::Named(Named::Backspace) => {
|
|
|
|
|
state.buffer.pop();
|
|
|
|
|
return (canvas::event::Status::Captured, None);
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
if let Some(text) = text.filter(|text| is_numeric_fragment(text)) {
|
|
|
|
|
state.buffer.push_str(&text);
|
|
|
|
|
return (canvas::event::Status::Captured, None);
|
|
|
|
|
}
|
|
|
|
|
} else if let Some(text) = text.filter(|text| is_numeric_fragment(text)) {
|
2026-07-21 17:40:41 +03:00
|
|
|
// Typing while hovering immediately starts editing; no click needed.
|
2026-07-21 04:25:23 +03:00
|
|
|
PlainSlider::<Message>::begin_typing(state, &text);
|
|
|
|
|
return (canvas::event::Status::Captured, None);
|
|
|
|
|
}
|
|
|
|
|
(canvas::event::Status::Ignored, None)
|
|
|
|
|
}
|
|
|
|
|
_ => (canvas::event::Status::Ignored, None),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn mouse_interaction(
|
|
|
|
|
&self,
|
|
|
|
|
_state: &Self::State,
|
|
|
|
|
bounds: Rectangle,
|
|
|
|
|
cursor: mouse::Cursor,
|
|
|
|
|
) -> mouse::Interaction {
|
|
|
|
|
if cursor.is_over(bounds) {
|
|
|
|
|
mouse::Interaction::Pointer
|
|
|
|
|
} else {
|
|
|
|
|
mouse::Interaction::default()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Builds a theme-aware slider element.
|
2026-07-19 00:55:40 +03:00
|
|
|
///
|
2026-07-21 04:25:23 +03:00
|
|
|
/// **Arguments:** Label/value/range define the numeric control; `step`, `suffix`, and `decimals`
|
|
|
|
|
/// control interaction and formatting; `colors` is the active palette; `on_change` creates the
|
|
|
|
|
/// parent message. **Returns:** A fill-width 22 px slider. **Side Effects:** None until interaction.
|
2026-07-19 00:55:40 +03:00
|
|
|
pub fn plain_slider<Message>(
|
|
|
|
|
label: impl Into<String>,
|
|
|
|
|
value: f32,
|
|
|
|
|
range: std::ops::RangeInclusive<f32>,
|
|
|
|
|
step: f32,
|
|
|
|
|
suffix: impl Into<String>,
|
|
|
|
|
decimals: usize,
|
2026-07-21 04:25:23 +03:00
|
|
|
colors: ThemeColors,
|
2026-07-19 00:55:40 +03:00
|
|
|
on_change: impl Fn(f32) -> Message + 'static,
|
|
|
|
|
) -> Element<'static, Message>
|
|
|
|
|
where
|
2026-07-21 04:25:23 +03:00
|
|
|
Message: 'static,
|
2026-07-19 00:55:40 +03:00
|
|
|
{
|
2026-07-21 04:25:23 +03:00
|
|
|
Canvas::new(PlainSlider {
|
|
|
|
|
label: label.into(),
|
|
|
|
|
value,
|
|
|
|
|
range,
|
|
|
|
|
step,
|
|
|
|
|
suffix: suffix.into(),
|
|
|
|
|
decimals,
|
|
|
|
|
colors,
|
|
|
|
|
on_change: Arc::new(on_change),
|
|
|
|
|
})
|
|
|
|
|
.width(Length::Fill)
|
|
|
|
|
.height(Length::Fixed(CONTROL_HEIGHT))
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns whether text can be appended to a numeric edit buffer.
|
|
|
|
|
fn is_numeric_fragment(text: &str) -> bool {
|
|
|
|
|
!text.is_empty()
|
|
|
|
|
&& text
|
|
|
|
|
.chars()
|
|
|
|
|
.all(|character| character.is_ascii_digit() || matches!(character, '.' | ',' | '-'))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 00:55:40 +03:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
2026-07-21 04:25:23 +03:00
|
|
|
use super::{is_numeric_fragment, PlainSlider, State};
|
|
|
|
|
use crate::theme::{ThemeColors, ThemePreset};
|
2026-07-19 00:55:40 +03:00
|
|
|
|
2026-07-21 04:25:23 +03:00
|
|
|
fn test_slider(suffix: &str) -> PlainSlider<()> {
|
|
|
|
|
PlainSlider {
|
|
|
|
|
label: "Opacity".to_string(),
|
|
|
|
|
value: 0.5,
|
|
|
|
|
range: 0.0..=1.0,
|
|
|
|
|
step: 0.01,
|
|
|
|
|
suffix: suffix.to_string(),
|
|
|
|
|
decimals: 0,
|
|
|
|
|
colors: ThemeColors::get(ThemePreset::Photopea),
|
|
|
|
|
on_change: std::sync::Arc::new(|_| ()),
|
|
|
|
|
}
|
2026-07-19 00:55:40 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-07-21 04:25:23 +03:00
|
|
|
fn percentage_format_parse_and_clamp_are_stable() {
|
|
|
|
|
let slider = test_slider("%");
|
|
|
|
|
assert_eq!(slider.format_value(0.5), "Opacity: 50%");
|
|
|
|
|
assert_eq!(slider.parse_value("75"), Some(0.75));
|
2026-07-19 00:55:40 +03:00
|
|
|
assert_eq!(slider.parse_value("150"), Some(1.0));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-07-21 04:25:23 +03:00
|
|
|
fn hover_typing_accepts_only_numeric_fragments() {
|
|
|
|
|
let mut state = State::default();
|
|
|
|
|
assert!(PlainSlider::<()>::begin_typing(&mut state, "-"));
|
|
|
|
|
assert!(state.editing);
|
|
|
|
|
assert_eq!(state.buffer, "-");
|
|
|
|
|
assert!(is_numeric_fragment("12.5"));
|
|
|
|
|
assert!(!is_numeric_fragment("px"));
|
2026-07-19 00:55:40 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-07-21 04:25:23 +03:00
|
|
|
fn pointer_mapping_respects_endpoints_and_step() {
|
|
|
|
|
let slider = test_slider("");
|
|
|
|
|
assert_eq!(slider.value_at(0.0, 124.0), 0.0);
|
|
|
|
|
assert_eq!(slider.value_at(100.0, 124.0), 1.0);
|
|
|
|
|
assert!((slider.value_at(33.0, 124.0) - 0.33).abs() < 1e-6);
|
2026-07-19 00:55:40 +03:00
|
|
|
}
|
2026-07-24 19:11:01 +03:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn pointer_mapping_clamps_outside_track() {
|
|
|
|
|
let slider = test_slider("");
|
|
|
|
|
assert_eq!(slider.value_at(-10.0, 124.0), 0.0);
|
|
|
|
|
assert_eq!(slider.value_at(120.0, 124.0), 1.0);
|
|
|
|
|
}
|
2026-07-19 00:55:40 +03:00
|
|
|
}
|