//! 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 { label: String, value: f32, range: std::ops::RangeInclusive, step: f32, suffix: String, decimals: usize, colors: ThemeColors, on_change: Arc Message>, } impl std::fmt::Debug for PlainSlider { 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 PlainSlider { /// 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 { let numeric = raw .trim() .trim_end_matches(&self.suffix) .trim() .replace(',', "."); let parsed = numeric.parse::().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 canvas::Program for PlainSlider { type State = State; fn draw( &self, state: &Self::State, renderer: &iced::Renderer, _theme: &iced::Theme, bounds: Rectangle, cursor: mouse::Cursor, ) -> Vec { 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) { 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::::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( label: impl Into, value: f32, range: std::ops::RangeInclusive, step: f32, suffix: impl Into, 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); } }