Skip to main content

truce_iced/widgets/
slider.rs

1//! Horizontal slider widget rendered via iced Canvas with relative drag.
2
3use std::fmt::Debug;
4use std::marker::PhantomData;
5
6use iced::widget::Canvas;
7use iced::widget::canvas::{self, Event, Frame, Geometry, Path, Stroke, Text};
8use iced::{Color, Element, Length, Point, Rectangle, Renderer, Theme, alignment, mouse};
9
10use crate::param_cache::ParamCache;
11use crate::param_message::{Message, ParamMessage};
12use crate::theme;
13use truce_core::Float;
14use truce_params::Params;
15
16const TRACK_HEIGHT: f32 = 4.0;
17const THUMB_RADIUS: f32 = 6.0;
18
19/// Builder for a parameter-bound horizontal slider.
20pub struct SliderWidget<'a, M> {
21    id: u32,
22    value: f64,
23    display: String,
24    label: Option<&'a str>,
25    width: f32,
26    font: iced::Font,
27    _phantom: PhantomData<M>,
28}
29
30impl<'a, M: Clone + Debug + 'static> SliderWidget<'a, M> {
31    pub fn new(id: impl Into<u32>, params: &'a ParamCache<impl Params>) -> Self {
32        let id = id.into();
33        Self {
34            id,
35            value: params.get(id),
36            display: params.label(id).to_string(),
37            label: None,
38            width: 120.0,
39            font: params.font(),
40            _phantom: PhantomData,
41        }
42    }
43
44    #[must_use]
45    pub fn label(mut self, label: &'a str) -> Self {
46        self.label = Some(label);
47        self
48    }
49
50    #[must_use]
51    pub fn width(mut self, width: f32) -> Self {
52        self.width = width;
53        self
54    }
55
56    #[must_use]
57    pub fn font(mut self, font: iced::Font) -> Self {
58        self.font = font;
59        self
60    }
61
62    #[must_use]
63    pub fn into_element(self) -> Element<'a, Message<M>> {
64        let total_h = THUMB_RADIUS * 2.0 + 30.0;
65        let program = SliderProgram {
66            id: self.id,
67            value: f32::from_f64(self.value),
68            display: self.display,
69            label: self.label.unwrap_or("").to_string(),
70            font: self.font,
71        };
72
73        Canvas::new(program)
74            .width(Length::Fixed(self.width))
75            .height(Length::Fixed(total_h))
76            .into()
77    }
78}
79
80impl<'a, M: Clone + Debug + 'static> From<SliderWidget<'a, M>> for Element<'a, Message<M>> {
81    fn from(s: SliderWidget<'a, M>) -> Self {
82        s.into_element()
83    }
84}
85
86// Canvas program
87
88struct SliderProgram {
89    id: u32,
90    value: f32,
91    display: String,
92    label: String,
93    font: iced::Font,
94}
95
96#[derive(Default)]
97struct SliderState {
98    dragging: bool,
99    start_value: f32,
100    start_x: f32,
101}
102
103impl<M: Clone + Debug + 'static> canvas::Program<Message<M>> for SliderProgram {
104    type State = SliderState;
105
106    fn draw(
107        &self,
108        _state: &Self::State,
109        renderer: &Renderer,
110        _theme: &Theme,
111        bounds: Rectangle,
112        _cursor: mouse::Cursor,
113    ) -> Vec<Geometry> {
114        let mut frame = Frame::new(renderer, bounds.size());
115
116        let margin = THUMB_RADIUS;
117        let track_y = THUMB_RADIUS;
118        let track_left = margin;
119        let track_right = bounds.width - margin;
120        let track_width = track_right - track_left;
121
122        // Track background
123        let track_bg = Path::line(
124            Point::new(track_left, track_y),
125            Point::new(track_right, track_y),
126        );
127        frame.stroke(
128            &track_bg,
129            Stroke::default()
130                .with_color(theme::KNOB_TRACK)
131                .with_width(TRACK_HEIGHT)
132                .with_line_cap(iced::widget::canvas::LineCap::Round),
133        );
134
135        // Filled portion
136        let fill_x = track_left + self.value * track_width;
137        if self.value > 0.001 {
138            let track_fill =
139                Path::line(Point::new(track_left, track_y), Point::new(fill_x, track_y));
140            frame.stroke(
141                &track_fill,
142                Stroke::default()
143                    .with_color(theme::KNOB_FILL)
144                    .with_width(TRACK_HEIGHT)
145                    .with_line_cap(iced::widget::canvas::LineCap::Round),
146            );
147        }
148
149        // Thumb
150        let thumb = Path::circle(Point::new(fill_x, track_y), THUMB_RADIUS);
151        frame.fill(&thumb, theme::KNOB_POINTER);
152
153        // Value text
154        let text_y = THUMB_RADIUS * 2.0 + 4.0;
155        let cx = bounds.width / 2.0;
156        frame.fill_text(Text {
157            content: self.display.clone(),
158            position: Point::new(cx, text_y),
159            color: Color::from_rgb(0.90, 0.90, 0.92),
160            size: iced::Pixels(11.0),
161            align_x: alignment::Horizontal::Center.into(),
162            align_y: alignment::Vertical::Top,
163            font: self.font,
164            ..Text::default()
165        });
166
167        // Label text
168        if !self.label.is_empty() {
169            let label_y = text_y + 14.0;
170            frame.fill_text(Text {
171                content: self.label.clone(),
172                position: Point::new(cx, label_y),
173                color: theme::TEXT_DIM,
174                size: iced::Pixels(10.0),
175                align_x: alignment::Horizontal::Center.into(),
176                align_y: alignment::Vertical::Top,
177                font: self.font,
178                ..Text::default()
179            });
180        }
181
182        vec![frame.into_geometry()]
183    }
184
185    fn update(
186        &self,
187        state: &mut Self::State,
188        event: &Event,
189        bounds: Rectangle,
190        cursor: mouse::Cursor,
191    ) -> Option<canvas::Action<Message<M>>> {
192        match event {
193            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
194                if let Some(pos) = cursor.position_in(bounds) {
195                    let track_top = 0.0;
196                    let track_bottom = THUMB_RADIUS * 2.0;
197                    if pos.y >= track_top && pos.y <= track_bottom {
198                        state.dragging = true;
199                        state.start_value = self.value;
200                        state.start_x = pos.x;
201                        return Some(
202                            canvas::Action::publish(Message::Param(ParamMessage::BeginEdit(
203                                self.id,
204                            )))
205                            .and_capture(),
206                        );
207                    }
208                }
209            }
210            Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
211                if let Some(pos) = cursor.position() {
212                    let current_x = pos.x - bounds.x;
213                    let track_width = bounds.width - THUMB_RADIUS * 2.0;
214                    let delta = (current_x - state.start_x) / track_width;
215                    let new_value = (state.start_value + delta).clamp(0.0, 1.0);
216                    return Some(
217                        canvas::Action::publish(Message::Param(ParamMessage::SetNormalized(
218                            self.id,
219                            f64::from(new_value),
220                        )))
221                        .and_capture(),
222                    );
223                }
224            }
225            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) if state.dragging => {
226                state.dragging = false;
227                return Some(
228                    canvas::Action::publish(Message::Param(ParamMessage::EndEdit(self.id)))
229                        .and_capture(),
230                );
231            }
232            _ => {}
233        }
234
235        None
236    }
237
238    fn mouse_interaction(
239        &self,
240        state: &Self::State,
241        bounds: Rectangle,
242        cursor: mouse::Cursor,
243    ) -> mouse::Interaction {
244        if state.dragging {
245            return mouse::Interaction::Grabbing;
246        }
247        if let Some(pos) = cursor.position_in(bounds)
248            && pos.y <= THUMB_RADIUS * 2.0
249        {
250            return mouse::Interaction::Grab;
251        }
252        mouse::Interaction::default()
253    }
254}