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