Skip to main content

fission_core/ui/widgets/
slider.rs

1use crate::lowering::wrap_zstack_child;
2use crate::ActionEnvelope;
3use crate::{Lower, LoweringContext, NodeBuilder};
4use fission_ir::{
5    op::{Color, Fill, GridTrack, LayoutOp, Op, PaintOp},
6    FlexDirection, NodeId,
7};
8use serde::{Deserialize, Serialize};
9
10/// A continuous value selector rendered as a horizontal track with a draggable
11/// thumb.
12///
13/// The thumb position is determined by `value` within the `[min, max]` range.
14/// Dragging dispatches the `on_change` action with the new value carried as
15/// pointer input (see [`ActionInput::as_pointer`]).
16///
17/// # Example
18///
19/// ```rust,ignore
20/// Slider {
21///     value: view.state.volume,
22///     min: 0.0,
23///     max: 1.0,
24///     on_change: Some(ctx.bind(
25///         VolumeChanged,
26///         reduce_with!(handle_volume),
27///     )),
28///     ..Default::default()
29/// }
30/// ```
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Slider {
33    /// Explicit node identity.
34    pub id: Option<NodeId>,
35    /// Current value (clamped to `[min, max]`).
36    pub value: f32,
37    /// Minimum value (default: 0.0).
38    pub min: f32,
39    /// Maximum value (default: 1.0).
40    pub max: f32,
41    /// Action dispatched when the user drags the thumb.
42    pub on_change: Option<ActionEnvelope>,
43}
44
45impl Slider {
46    pub fn into_node(self) -> crate::ui::Node {
47        crate::ui::Node::Slider(self)
48    }
49}
50
51impl Default for Slider {
52    fn default() -> Self {
53        Self {
54            id: None,
55            value: 0.0,
56            min: 0.0,
57            max: 1.0,
58            on_change: None,
59        }
60    }
61}
62
63impl Lower for Slider {
64    fn lower(&self, cx: &mut LoweringContext) -> NodeId {
65        let id = self.id.unwrap_or_else(|| cx.next_node_id());
66        cx.push_scope(id);
67
68        let tokens = &cx.env.theme.tokens;
69        let thumb_size = 16.0;
70        let track_height = 4.0;
71
72        let range = (self.max - self.min).max(0.0001);
73        let pct = ((self.value - self.min) / range).clamp(0.0, 1.0) * 100.0;
74
75        // Visual Structure:
76        // Grid [Percent(pct), Fixed(thumb), Auto]
77        // Row 1: Height(max(track, thumb))
78
79        // Track Background: DrawRect on the main container (centered vertically?)
80        // Actually, we want the track to be vertically centered.
81        // And Thumb centered.
82
83        // Let's make the root a Grid.
84        // It has a background PaintOp for the Track line?
85        // If we paint on Root, it fills the whole area.
86        // We want track to be thin.
87        // So we need a child for Track?
88        // But Track must span the whole width.
89        // Use ZStack.
90        // Layer 1: Track (Centered vertically).
91        // Layer 2: Thumb Grid.
92
93        let layout_id = cx.next_node_id();
94
95        // Layer 1: Track
96        // A Box with height `track_height`, centered?
97        // ZStack stretches children.
98        // We can use a Column with `Justify: Center` inside ZStack layer?
99        // Or just use `padding` on a box to squish the paint?
100        // `PaintOp` `DrawRect` fills the node layout.
101        // If we want a thin line, the node layout must be thin.
102        // `LayoutOp::Flex` (Column) with `AlignItems: Stretch` and `JustifyContent: Center`.
103        // Add a child Box with height `track_height`.
104        let track_layer = {
105            let track_paint = NodeBuilder::new(
106                cx.next_node_id(),
107                Op::Paint(PaintOp::DrawRect {
108                    fill: Some(Fill::Solid(tokens.colors.border)), // Inactive track
109                    stroke: None,
110                    corner_radius: track_height / 2.0,
111                    shadow: None,
112                }),
113            )
114            .build(cx);
115
116            let mut track_box = NodeBuilder::new(
117                cx.next_node_id(),
118                Op::Layout(LayoutOp::Box {
119                    width: None, // Auto width
120                    height: Some(track_height),
121                    min_width: None,
122                    max_width: None,
123                    min_height: None,
124                    max_height: None,
125                    padding: [0.0; 4],
126                    flex_grow: 0.0,
127                    flex_shrink: 0.0,
128                    aspect_ratio: None,
129                }),
130            );
131            track_box.add_child(track_paint);
132            let _track_box_id = track_box.build(cx);
133
134            // Center vertically
135            let _center_col = NodeBuilder::new(
136                cx.next_node_id(),
137                Op::Layout(LayoutOp::Flex {
138                    direction: FlexDirection::Row,
139                    wrap: fission_ir::op::FlexWrap::NoWrap,
140                    flex_grow: 0.0,
141                    flex_shrink: 0.0,
142                    padding: [0.0; 4],
143                    gap: None,
144                    align_items: fission_ir::op::AlignItems::Center,
145                    justify_content: fission_ir::op::JustifyContent::Start,
146                }),
147            );
148            // We need `justify_content: center`. `LayoutOp::Flex` maps to `AlignItems: Center` (cross axis) but justification?
149            // `fission-layout` hardcodes `justify_content: FlexStart`.
150            // So we can't center easily using Flex properties exposed currently.
151            // Workaround: Use `Grid` 1x1 with `AlignItems: Center`?
152            // `fission-layout` Grid mapping doesn't expose alignment yet.
153
154            // Workaround 2: Use Padding? We don't know height.
155            // Workaround 3: Make the Thumb layer determine height, and Track stretches?
156            // No, Track is thin.
157
158            // Workaround 4: Paint the track as a `DrawRect` on the ROOT node, but use `stroke` instead of `fill`?
159            // Stroke is centered on border? No, stroke is usually inset or centered.
160            // If we have `PaintOp::DrawLine`? No.
161
162            // Let's assume the Slider height IS the thumb size.
163            // We paint the track by `DrawRect` with custom logic? No, standard ops.
164
165            // Best approach given constraints:
166            // Use `LayoutOp::Box` with top/bottom padding calculated to center the track?
167            // `padding_top = (thumb_size - track_height) / 2`.
168            let p_y = (thumb_size - track_height) / 2.0;
169
170            let mut track_container = NodeBuilder::new(
171                cx.next_node_id(),
172                Op::Layout(LayoutOp::Box {
173                    width: None,
174                    height: None,
175                    min_width: None,
176                    max_width: None,
177                    min_height: None,
178                    max_height: None,
179                    padding: [0.0, 0.0, p_y, p_y],
180                    flex_grow: 0.0,
181                    flex_shrink: 0.0,
182                    aspect_ratio: None,
183                }),
184            );
185
186            let inner_paint = NodeBuilder::new(
187                cx.next_node_id(),
188                Op::Paint(PaintOp::DrawRect {
189                    fill: Some(Fill::Solid(tokens.colors.border)),
190                    stroke: None,
191                    corner_radius: track_height / 2.0,
192                    shadow: None,
193                }),
194            )
195            .build(cx);
196
197            // We need inner box to have height `track_height`.
198            // The padding pushes the content in.
199            // The inner box fills the remaining height.
200            // If container height is `thumb_size`, and padding is `(thumb-track)/2`, remaining is `track`.
201            // But container height is `Auto` (driven by ZStack constraint?).
202            // ZStack constraints are "largest child".
203            // If Thumb layer is `thumb_size` height, Root is `thumb_size`.
204            // Track container fills Root.
205
206            // So yes, Padding approach works if Root height is constrained by Thumb.
207
208            // But `inner_paint` needs a layout node to fill?
209            let mut inner_box =
210                NodeBuilder::new(cx.next_node_id(), Op::Layout(LayoutOp::AbsoluteFill)); // Fill the padded area
211            inner_box.add_child(inner_paint);
212            let inner_id = inner_box.build(cx);
213
214            track_container.add_child(inner_id);
215            track_container.build(cx)
216        };
217
218        // Layer 2: Thumb Grid
219        let thumb_layer = {
220            let thumb_paint = NodeBuilder::new(
221                cx.next_node_id(),
222                Op::Paint(PaintOp::DrawRect {
223                    fill: Some(Fill::Solid(tokens.colors.primary)),
224                    stroke: None,
225                    corner_radius: thumb_size / 2.0,
226                    shadow: Some(fission_ir::op::BoxShadow {
227                        color: Color {
228                            r: 0,
229                            g: 0,
230                            b: 0,
231                            a: 50,
232                        },
233                        blur_radius: 2.0,
234                        offset: (0.0, 1.0),
235                    }),
236                }),
237            )
238            .build(cx);
239
240            let mut thumb_box = NodeBuilder::new(
241                cx.next_node_id(),
242                Op::Layout(LayoutOp::Box {
243                    width: Some(thumb_size),
244                    height: Some(thumb_size),
245                    min_width: None,
246                    max_width: None,
247                    min_height: None,
248                    max_height: None,
249                    padding: [0.0; 4],
250                    flex_grow: 0.0,
251                    flex_shrink: 0.0,
252                    aspect_ratio: None,
253                }),
254            );
255            thumb_box.add_child(thumb_paint);
256            let thumb_id = thumb_box.build(cx);
257
258            let mut grid = NodeBuilder::new(
259                cx.next_node_id(),
260                Op::Layout(LayoutOp::Grid {
261                    columns: vec![
262                        GridTrack::Percent(pct),
263                        GridTrack::Points(thumb_size),
264                        GridTrack::Fr(1.0),
265                    ],
266                    rows: vec![GridTrack::Points(thumb_size)],
267                    column_gap: None,
268                    row_gap: None,
269                    padding: [0.0; 4],
270                }),
271            );
272
273            // Thumb item at col 2
274            let mut item = NodeBuilder::new(
275                cx.next_node_id(),
276                Op::Layout(LayoutOp::GridItem {
277                    row_start: fission_ir::op::GridPlacement::Line(1),
278                    row_end: fission_ir::op::GridPlacement::Auto,
279                    col_start: fission_ir::op::GridPlacement::Line(2),
280                    col_end: fission_ir::op::GridPlacement::Auto,
281                }),
282            );
283            item.add_child(thumb_id);
284            let item_id = item.build(cx);
285
286            grid.add_child(item_id);
287            grid.build(cx)
288        };
289
290        cx.push_scope(layout_id);
291        let track_wrapped = wrap_zstack_child(cx, track_layer);
292        let thumb_wrapped = wrap_zstack_child(cx, thumb_layer);
293        cx.pop_scope();
294
295        let mut zstack = NodeBuilder::new(layout_id, Op::Layout(LayoutOp::ZStack));
296        zstack.add_child(track_wrapped);
297        zstack.add_child(thumb_wrapped);
298        zstack.build(cx);
299
300        cx.pop_scope();
301
302        let mut semantics = fission_ir::Semantics {
303            role: fission_ir::Role::Slider,
304            label: None,
305            identifier: None,
306            value: Some(format!("{:.2}", self.value)),
307            actions: Default::default(),
308            action_scope_id: None,
309            focusable: true,
310            multiline: false,
311            masked: false,
312            input_mask: None,
313            ime_preedit_range: None,
314            checked: None,
315            disabled: false,
316            read_only: false,
317            autofocus: false,
318            draggable: true,
319            scrollable_x: false,
320            scrollable_y: false,
321            min_value: Some(self.min),
322            max_value: Some(self.max),
323            current_value: Some(self.value),
324            is_focus_scope: false,
325            is_focus_barrier: false,
326            drag_payload: None,
327            hero_tag: None,
328            focus_index: None,
329            text_input_type: fission_ir::semantics::TextInputType::Text,
330            text_input_action: fission_ir::semantics::TextInputAction::Done,
331            text_capitalization: fission_ir::semantics::TextCapitalization::None,
332            max_length: None,
333            max_length_enforcement: fission_ir::semantics::MaxLengthEnforcement::Enforced,
334            input_formatters: Vec::new(),
335            autocorrect: true,
336            enable_suggestions: true,
337            spell_check: true,
338            smart_dashes: true,
339            smart_quotes: true,
340            autofill_hints: Vec::new(),
341            scroll_padding: None,
342            capture_tab: false,
343            auto_indent: false,
344        };
345
346        if let Some(action) = &self.on_change {
347            semantics.actions.entries.push(fission_ir::ActionEntry {
348                trigger: fission_ir::semantics::ActionTrigger::Change,
349                action_id: action.id.as_u128(),
350                payload_data: Some(action.payload.clone()),
351            });
352        }
353
354        let mut sem_node = NodeBuilder::new(id, Op::Semantics(semantics));
355        sem_node.add_child(layout_id);
356        sem_node.build(cx)
357    }
358}