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}