Skip to main content

fission_core/ui/widgets/
slider.rs

1use crate::internal::InternalLower;
2use crate::lowering::wrap_zstack_child;
3use crate::lowering::{InternalIrBuilder, InternalLoweringCx};
4use crate::ActionEnvelope;
5use fission_ir::{
6    op::{Color, Fill, GridTrack, LayoutOp, Op, PaintOp},
7    WidgetId,
8};
9use serde::{Deserialize, Serialize};
10
11/// A continuous value selector rendered as a horizontal track with a draggable
12/// thumb.
13///
14/// The thumb position is determined by `value` within the `[min, max]` range.
15/// Dragging dispatches the `on_change` action with the new value carried as
16/// pointer input (see [`crate::ActionInput::as_pointer`]).
17///
18/// # Example
19///
20/// ```rust,ignore
21/// Slider {
22///     value: view.state().volume,
23///     min: 0.0,
24///     max: 1.0,
25///     on_change: Some(ctx.bind(
26///         VolumeChanged,
27///         reduce_with!(handle_volume),
28///     )),
29///     ..Default::default()
30/// }
31/// ```
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Slider {
34    /// Explicit node identity.
35    pub id: Option<WidgetId>,
36    /// Current value (clamped to `[min, max]`).
37    pub value: f32,
38    /// Minimum value (default: 0.0).
39    pub min: f32,
40    /// Maximum value (default: 1.0).
41    pub max: f32,
42    /// Action dispatched when the user drags the thumb.
43    pub on_change: Option<ActionEnvelope>,
44}
45
46impl Slider {}
47
48impl Default for Slider {
49    fn default() -> Self {
50        Self {
51            id: None,
52            value: 0.0,
53            min: 0.0,
54            max: 1.0,
55            on_change: None,
56        }
57    }
58}
59
60impl InternalLower for Slider {
61    fn lower(&self, cx: &mut InternalLoweringCx) -> WidgetId {
62        let id = self.id.map(Into::into).unwrap_or_else(|| cx.next_node_id());
63        cx.push_scope(id);
64
65        let tokens = &cx.env.theme.tokens;
66        let thumb_size = 16.0;
67        let track_height = 4.0;
68
69        let range = (self.max - self.min).max(0.0001);
70        let pct = ((self.value - self.min) / range).clamp(0.0, 1.0) * 100.0;
71
72        // Visual Structure:
73        // Grid [Percent(pct), Fixed(thumb), Auto]
74        // Row 1: Height(max(track, thumb))
75
76        // Track Background: DrawRect on the main container (centered vertically?)
77        // Actually, we want the track to be vertically centered.
78        // And Thumb centered.
79
80        // Let's make the root a Grid.
81        // It has a background PaintOp for the Track line?
82        // If we paint on Root, it fills the whole area.
83        // We want track to be thin.
84        // So we need a child for Track?
85        // But Track must span the whole width.
86        // Use ZStack.
87        // Layer 1: Track (Centered vertically).
88        // Layer 2: Thumb Grid.
89
90        let layout_id = cx.next_node_id();
91
92        let track_layer = {
93            // Center the thin track in the thumb-height z-stack without
94            // materialising detached helper nodes in the IR.
95            let p_y = (thumb_size - track_height) / 2.0;
96
97            let mut track_container = InternalIrBuilder::new(
98                cx.next_node_id(),
99                Op::Layout(LayoutOp::Box {
100                    width: None,
101                    height: None,
102                    min_width: None,
103                    max_width: None,
104                    min_height: None,
105                    max_height: None,
106                    padding: [0.0, 0.0, p_y, p_y],
107                    flex_grow: 0.0,
108                    flex_shrink: 0.0,
109                    aspect_ratio: None,
110                }),
111            );
112
113            let inner_paint = InternalIrBuilder::new(
114                cx.next_node_id(),
115                Op::Paint(PaintOp::DrawRect {
116                    fill: Some(Fill::Solid(tokens.colors.border)),
117                    stroke: None,
118                    corner_radius: track_height / 2.0,
119                    shadow: None,
120                }),
121            )
122            .build(cx);
123
124            // We need inner box to have height `track_height`.
125            // The padding pushes the content in.
126            // The inner box fills the remaining height.
127            // If container height is `thumb_size`, and padding is `(thumb-track)/2`, remaining is `track`.
128            // But container height is `Auto` (driven by ZStack constraint?).
129            // ZStack constraints are "largest child".
130            // If Thumb layer is `thumb_size` height, Root is `thumb_size`.
131            // Track container fills Root.
132
133            // So yes, Padding approach works if Root height is constrained by Thumb.
134
135            // But `inner_paint` needs a layout node to fill?
136            let mut inner_box =
137                InternalIrBuilder::new(cx.next_node_id(), Op::Layout(LayoutOp::AbsoluteFill)); // Fill the padded area
138            inner_box.add_child(inner_paint);
139            let inner_id = inner_box.build(cx);
140
141            track_container.add_child(inner_id);
142            track_container.build(cx)
143        };
144
145        // Layer 2: Thumb Grid
146        let thumb_layer = {
147            let thumb_paint = InternalIrBuilder::new(
148                cx.next_node_id(),
149                Op::Paint(PaintOp::DrawRect {
150                    fill: Some(Fill::Solid(tokens.colors.primary)),
151                    stroke: None,
152                    corner_radius: thumb_size / 2.0,
153                    shadow: Some(fission_ir::op::BoxShadow {
154                        color: Color {
155                            r: 0,
156                            g: 0,
157                            b: 0,
158                            a: 50,
159                        },
160                        blur_radius: 2.0,
161                        offset: (0.0, 1.0),
162                    }),
163                }),
164            )
165            .build(cx);
166
167            let mut thumb_box = InternalIrBuilder::new(
168                cx.next_node_id(),
169                Op::Layout(LayoutOp::Box {
170                    width: Some(thumb_size),
171                    height: Some(thumb_size),
172                    min_width: None,
173                    max_width: None,
174                    min_height: None,
175                    max_height: None,
176                    padding: [0.0; 4],
177                    flex_grow: 0.0,
178                    flex_shrink: 0.0,
179                    aspect_ratio: None,
180                }),
181            );
182            thumb_box.add_child(thumb_paint);
183            let thumb_id = thumb_box.build(cx);
184
185            let mut grid = InternalIrBuilder::new(
186                cx.next_node_id(),
187                Op::Layout(LayoutOp::Grid {
188                    columns: vec![
189                        GridTrack::Percent(pct),
190                        GridTrack::Points(thumb_size),
191                        GridTrack::Fr(1.0),
192                    ],
193                    rows: vec![GridTrack::Points(thumb_size)],
194                    column_gap: None,
195                    row_gap: None,
196                    padding: [0.0; 4],
197                }),
198            );
199
200            // Thumb item at col 2
201            let mut item = InternalIrBuilder::new(
202                cx.next_node_id(),
203                Op::Layout(LayoutOp::GridItem {
204                    row_start: fission_ir::op::GridPlacement::Line(1),
205                    row_end: fission_ir::op::GridPlacement::Auto,
206                    col_start: fission_ir::op::GridPlacement::Line(2),
207                    col_end: fission_ir::op::GridPlacement::Auto,
208                }),
209            );
210            item.add_child(thumb_id);
211            let item_id = item.build(cx);
212
213            grid.add_child(item_id);
214            grid.build(cx)
215        };
216
217        cx.push_scope(layout_id);
218        let track_wrapped = wrap_zstack_child(cx, track_layer);
219        let thumb_wrapped = wrap_zstack_child(cx, thumb_layer);
220        cx.pop_scope();
221
222        let mut zstack = InternalIrBuilder::new(layout_id, Op::Layout(LayoutOp::ZStack));
223        zstack.add_child(track_wrapped);
224        zstack.add_child(thumb_wrapped);
225        zstack.build(cx);
226
227        cx.pop_scope();
228
229        let mut semantics = fission_ir::Semantics {
230            role: fission_ir::Role::Slider,
231            label: None,
232            identifier: None,
233            value: Some(format!("{:.2}", self.value)),
234            actions: Default::default(),
235            action_scope_id: None,
236            focusable: true,
237            multiline: false,
238            masked: false,
239            input_mask: None,
240            ime_preedit_range: None,
241            checked: None,
242            disabled: false,
243            read_only: false,
244            autofocus: false,
245            draggable: true,
246            scrollable_x: false,
247            scrollable_y: false,
248            min_value: Some(self.min),
249            max_value: Some(self.max),
250            current_value: Some(self.value),
251            is_focus_scope: false,
252            is_focus_barrier: false,
253            drag_payload: None,
254            hero_tag: None,
255            focus_index: None,
256            text_input_type: fission_ir::semantics::TextInputType::Text,
257            text_input_action: fission_ir::semantics::TextInputAction::Done,
258            text_capitalization: fission_ir::semantics::TextCapitalization::None,
259            max_length: None,
260            max_length_enforcement: fission_ir::semantics::MaxLengthEnforcement::Enforced,
261            input_formatters: Vec::new(),
262            autocorrect: true,
263            enable_suggestions: true,
264            spell_check: true,
265            smart_dashes: true,
266            smart_quotes: true,
267            autofill_hints: Vec::new(),
268            scroll_padding: None,
269            capture_tab: false,
270            auto_indent: false,
271        };
272
273        if let Some(action) = &self.on_change {
274            semantics.actions.entries.push(fission_ir::ActionEntry {
275                trigger: fission_ir::semantics::ActionTrigger::Change,
276                action_id: action.id.as_u128(),
277                payload_data: Some(action.payload.clone()),
278            });
279        }
280
281        let mut sem_node = InternalIrBuilder::new(id, Op::Semantics(semantics));
282        sem_node.add_child(layout_id);
283        sem_node.build(cx)
284    }
285}