Files
hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/widgets/plain_slider.rs
T
Your Name 844fd4a026
mandatory-regression-gate / deterministic-tests (push) Has been cancelled
mandatory-regression-gate / protected-performance-path (push) Has been cancelled
feat: enhance PlainSlider to eliminate visual artifacts and improve boundary handling
2026-07-24 19:11:01 +03:00

467 lines
17 KiB
Rust

//! Theme-aware progress slider with hover-activated numeric entry.
//!
//! **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.
//! **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.
//! **Side Effects / Dependencies:** Emits caller-provided messages; all state remains local to the
//! widget tree and all colors derive from `ThemeColors`.
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};
use std::sync::Arc;
const CONTROL_HEIGHT: f32 = 22.0;
const SPINNER_WIDTH: f32 = 24.0;
/// Persistent interaction state for one slider instance.
#[derive(Debug, Default)]
pub(crate) struct State {
dragging: bool,
editing: bool,
buffer: String,
}
/// Canvas program describing one plain slider.
#[derive(Clone)]
struct PlainSlider<Message> {
label: String,
value: f32,
range: std::ops::RangeInclusive<f32>,
step: f32,
suffix: String,
decimals: usize,
colors: ThemeColors,
on_change: Arc<dyn Fn(f32) -> Message>,
}
impl<Message> std::fmt::Debug for PlainSlider<Message> {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.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)
.finish_non_exhaustive()
}
}
impl<Message> PlainSlider<Message> {
/// Formats the current value for centered display.
fn format_value(&self, value: f32) -> String {
let displayed = if self.suffix == "%" {
value * 100.0
} else {
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)
}
}
/// Parses an edit buffer, normalizes percentages, and clamps to the slider range.
fn parse_value(&self, raw: &str) -> Option<f32> {
let numeric = raw
.trim()
.trim_end_matches(&self.suffix)
.trim()
.replace(',', ".");
let parsed = numeric.parse::<f32>().ok()?;
let value = if self.suffix == "%" {
parsed / 100.0
} else {
parsed
};
Some(self.snap(value))
}
/// 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)
}
/// 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)
}
/// 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
}
}
}
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 {
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);
}
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);
};
// Spinner arrows remain step controls.
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)),
);
}
// Anywhere else on the track starts a drag; explicit edit mode
// is no longer required before typing.
state.editing = false;
state.buffer.clear();
state.dragging = true;
let value = self.value_at(position.x, bounds.width);
(
canvas::event::Status::Captured,
Some((self.on_change)(value)),
)
}
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)
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
let value = self.drag_value(cursor, bounds);
return (
canvas::event::Status::Captured,
Some((self.on_change)(value)),
);
}
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)) {
// Typing while hovering immediately starts editing; no click needed.
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.
///
/// **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.
pub fn plain_slider<Message>(
label: impl Into<String>,
value: f32,
range: std::ops::RangeInclusive<f32>,
step: f32,
suffix: impl Into<String>,
decimals: usize,
colors: ThemeColors,
on_change: impl Fn(f32) -> Message + 'static,
) -> Element<'static, Message>
where
Message: 'static,
{
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, '.' | ',' | '-'))
}
#[cfg(test)]
mod tests {
use super::{is_numeric_fragment, PlainSlider, State};
use crate::theme::{ThemeColors, ThemePreset};
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(|_| ()),
}
}
#[test]
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));
assert_eq!(slider.parse_value("150"), Some(1.0));
}
#[test]
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"));
}
#[test]
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);
}
#[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);
}
}