Skip to main content

fission_core/ui/widgets/
slider.rs

1use crate::{Lower, LoweringContext, Node, NodeBuilder};
2use crate::lowering::wrap_zstack_child;
3use crate::ActionEnvelope;
4use fission_ir::{
5    op::{Color, Fill, GridTrack, LayoutOp, Op, PaintOp, Stroke},
6    NodeId, FlexDirection,
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///         handle_volume as fn(&mut S, VolumeChanged),
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 { color: tokens.colors.border }), // Inactive track
109                    stroke: None,
110                    corner_radius: track_height / 2.0,
111                    shadow: None,
112                })
113            ).build(cx);
114            
115            let mut track_box = NodeBuilder::new(
116                cx.next_node_id(),
117                Op::Layout(LayoutOp::Box {
118                    width: None, // Auto width
119                    height: Some(track_height),
120                    min_width: None, max_width: None, min_height: None, max_height: None,
121                    padding: [0.0; 4],
122                    flex_grow: 0.0,
123                    flex_shrink: 0.0,
124                    aspect_ratio: None,
125                })
126            );
127            track_box.add_child(track_paint);
128            let track_box_id = track_box.build(cx);
129            
130            // Center vertically
131            let mut center_col = NodeBuilder::new(
132                cx.next_node_id(),
133                Op::Layout(LayoutOp::Flex {
134                    direction: FlexDirection::Row,
135                    wrap: fission_ir::op::FlexWrap::NoWrap,
136                    flex_grow: 0.0,
137                    flex_shrink: 0.0,
138                    padding: [0.0; 4],
139                    gap: None,
140                    align_items: fission_ir::op::AlignItems::Center,
141                    justify_content: fission_ir::op::JustifyContent::Start,
142                }),
143            );
144            // We need `justify_content: center`. `LayoutOp::Flex` maps to `AlignItems: Center` (cross axis) but justification?
145            // `fission-layout` hardcodes `justify_content: FlexStart`.
146            // So we can't center easily using Flex properties exposed currently.
147            // Workaround: Use `Grid` 1x1 with `AlignItems: Center`?
148            // `fission-layout` Grid mapping doesn't expose alignment yet.
149            
150            // Workaround 2: Use Padding? We don't know height.
151            // Workaround 3: Make the Thumb layer determine height, and Track stretches?
152            // No, Track is thin.
153            
154            // Workaround 4: Paint the track as a `DrawRect` on the ROOT node, but use `stroke` instead of `fill`?
155            // Stroke is centered on border? No, stroke is usually inset or centered.
156            // If we have `PaintOp::DrawLine`? No.
157            
158            // Let's assume the Slider height IS the thumb size.
159            // We paint the track by `DrawRect` with custom logic? No, standard ops.
160            
161            // Best approach given constraints:
162            // Use `LayoutOp::Box` with top/bottom padding calculated to center the track?
163            // `padding_top = (thumb_size - track_height) / 2`.
164            let p_y = (thumb_size - track_height) / 2.0;
165            
166            let mut track_container = NodeBuilder::new(
167                cx.next_node_id(),
168                Op::Layout(LayoutOp::Box {
169                    width: None, height: None, 
170                    min_width: None, max_width: None, min_height: None, max_height: None,
171                    padding: [0.0, 0.0, p_y, p_y],
172                    flex_grow: 0.0,
173                    flex_shrink: 0.0,
174                    aspect_ratio: None,
175                })
176            );
177            
178            let inner_paint = NodeBuilder::new(
179                cx.next_node_id(),
180                Op::Paint(PaintOp::DrawRect {
181                    fill: Some(Fill { color: tokens.colors.border }),
182                    stroke: None,
183                    corner_radius: track_height / 2.0,
184                    shadow: None,
185                })
186            ).build(cx);
187            
188            // We need inner box to have height `track_height`.
189            // The padding pushes the content in.
190            // The inner box fills the remaining height.
191            // If container height is `thumb_size`, and padding is `(thumb-track)/2`, remaining is `track`.
192            // But container height is `Auto` (driven by ZStack constraint?).
193            // ZStack constraints are "largest child".
194            // If Thumb layer is `thumb_size` height, Root is `thumb_size`.
195            // Track container fills Root.
196            
197            // So yes, Padding approach works if Root height is constrained by Thumb.
198            
199            // But `inner_paint` needs a layout node to fill?
200            let mut inner_box = NodeBuilder::new(cx.next_node_id(), Op::Layout(LayoutOp::AbsoluteFill)); // Fill the padded area
201            inner_box.add_child(inner_paint);
202            let inner_id = inner_box.build(cx);
203            
204            track_container.add_child(inner_id);
205            track_container.build(cx)
206        };
207
208        // Layer 2: Thumb Grid
209        let thumb_layer = {
210            let thumb_paint = NodeBuilder::new(
211                cx.next_node_id(),
212                Op::Paint(PaintOp::DrawRect {
213                    fill: Some(Fill { color: tokens.colors.primary }),
214                    stroke: None,
215                    corner_radius: thumb_size / 2.0,
216                    shadow: Some(fission_ir::op::BoxShadow {
217                        color: Color { r:0,g:0,b:0,a:50 },
218                        blur_radius: 2.0,
219                        offset: (0.0, 1.0)
220                    }),
221                })
222            ).build(cx);
223            
224            let mut thumb_box = NodeBuilder::new(
225                cx.next_node_id(),
226                Op::Layout(LayoutOp::Box {
227                    width: Some(thumb_size),
228                    height: Some(thumb_size),
229                    min_width: None, max_width: None, min_height: None, max_height: None,
230                    padding: [0.0; 4],
231                    flex_grow: 0.0,
232                    flex_shrink: 0.0,
233                    aspect_ratio: None,
234                })
235            );
236            thumb_box.add_child(thumb_paint);
237            let thumb_id = thumb_box.build(cx);
238            
239            let mut grid = NodeBuilder::new(
240                cx.next_node_id(),
241                Op::Layout(LayoutOp::Grid {
242                    columns: vec![GridTrack::Percent(pct), GridTrack::Points(thumb_size), GridTrack::Fr(1.0)],
243                    rows: vec![GridTrack::Points(thumb_size)],
244                    column_gap: None, row_gap: None, padding: [0.0; 4]
245                })
246            );
247            
248            // Thumb item at col 2
249            let mut item = NodeBuilder::new(
250                cx.next_node_id(),
251                Op::Layout(LayoutOp::GridItem {
252                    row_start: fission_ir::op::GridPlacement::Line(1),
253                    row_end: fission_ir::op::GridPlacement::Auto,
254                    col_start: fission_ir::op::GridPlacement::Line(2),
255                    col_end: fission_ir::op::GridPlacement::Auto,
256                })
257            );
258            item.add_child(thumb_id);
259            let item_id = item.build(cx);
260            
261            grid.add_child(item_id);
262            grid.build(cx)
263        };
264        
265        cx.push_scope(layout_id);
266        let track_wrapped = wrap_zstack_child(cx, track_layer);
267        let thumb_wrapped = wrap_zstack_child(cx, thumb_layer);
268        cx.pop_scope();
269
270        let mut zstack = NodeBuilder::new(layout_id, Op::Layout(LayoutOp::ZStack));
271        zstack.add_child(track_wrapped);
272        zstack.add_child(thumb_wrapped);
273        zstack.build(cx);
274
275        cx.pop_scope();
276
277        let mut semantics = fission_ir::Semantics {
278            role: fission_ir::Role::Slider,
279            label: None,
280            value: Some(format!("{:.2}", self.value)),
281            actions: Default::default(),
282            focusable: true,
283            multiline: false,
284            masked: false,
285            input_mask: None,
286            ime_preedit_range: None,
287            checked: None,
288            disabled: false,
289            draggable: true,
290            scrollable_x: false,
291            scrollable_y: false,
292            min_value: Some(self.min),
293            max_value: Some(self.max),
294            current_value: Some(self.value),
295            is_focus_scope: false,
296            is_focus_barrier: false,
297            drag_payload: None,
298            hero_tag: None,
299            focus_index: None, capture_tab: false, auto_indent: false,
300        };
301        
302        if let Some(action) = &self.on_change {
303             semantics.actions.entries.push(fission_ir::ActionEntry { 
304                 trigger: fission_ir::semantics::ActionTrigger::Change,
305                 action_id: action.id.as_u128(), 
306                 payload_data: Some(action.payload.clone()) 
307             });
308        }
309        
310        let mut sem_node = NodeBuilder::new(id, Op::Semantics(semantics));
311        sem_node.add_child(layout_id);
312        sem_node.build(cx)
313    }
314}
315