1use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
18use teksilo_core::accessibility::AccessNodeBuilder;
19use teksilo_core::binding::BindingLevel;
20use teksilo_core::build_context::BuildContext;
21use teksilo_core::focus::FocusOrigin;
22use teksilo_core::signal::Signal;
23use teksilo_core::styles::{SliderOrientation, SliderStyle, SliderStyleConfig, SliderVariant};
24use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
25use teksilo_core::widget_id::WidgetId;
26use teksilo_tokens::CornerRadius;
27
28const MIN_CROSS_SIZE: f32 = 24.0;
31
32pub const SLIDER_TRACK_HEIGHT: f32 = 4.0;
34pub const SLIDER_THUMB_DIAMETER: f32 = 14.0;
35pub const SLIDER_TICK_SIZE: f32 = 2.0;
36
37#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct SliderRecipe {
40 pub track_height: f32,
41 pub thumb_diameter: f32,
42 pub tick_size: f32,
43}
44
45impl Default for SliderRecipe {
46 fn default() -> Self {
47 Self {
48 track_height: SLIDER_TRACK_HEIGHT,
49 thumb_diameter: SLIDER_THUMB_DIAMETER,
50 tick_size: SLIDER_TICK_SIZE,
51 }
52 }
53}
54
55#[derive(Debug, Default, Clone, Copy)]
58pub struct RecipeSliderStyle {
59 pub recipe: SliderRecipe,
60}
61
62impl RecipeSliderStyle {
63 pub fn new(recipe: SliderRecipe) -> Self {
64 Self { recipe }
65 }
66}
67
68impl SliderStyle for RecipeSliderStyle {
69 fn thumb_diameter(&self, _cfg: &SliderStyleConfig) -> f32 {
70 self.recipe.thumb_diameter
71 }
72
73 fn make_body(&self, cfg: &SliderStyleConfig, ctx: &mut BuildContext) -> WidgetId {
74 ctx.add(SliderBody {
75 recipe: self.recipe,
76 value_normalized: cfg.value_normalized.clone(),
77 is_hovered: cfg.is_hovered.clone(),
78 is_dragging: cfg.is_dragging.clone(),
79 is_disabled: cfg.is_disabled.clone(),
80 focus_origin: cfg.focus_origin.clone(),
81 orientation: cfg.orientation,
82 tick_count: cfg.tick_count,
83 variant: cfg.variant,
84 })
85 }
86}
87
88struct SliderBody {
92 recipe: SliderRecipe,
93 value_normalized: Signal<f32>,
94 is_hovered: Signal<bool>,
95 is_dragging: Signal<bool>,
96 is_disabled: Signal<bool>,
97 focus_origin: Signal<Option<FocusOrigin>>,
98 orientation: SliderOrientation,
99 tick_count: Option<u32>,
100 variant: SliderVariant,
101}
102
103impl std::fmt::Debug for SliderBody {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 f.debug_struct("SliderBody")
106 .field("orientation", &self.orientation)
107 .field("variant", &self.variant)
108 .finish()
109 }
110}
111
112impl Widget for SliderBody {
113 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
114 let id = ctx.self_id();
115 let registry = ctx.binding_registry();
116 self.value_normalized
117 .bind_to(id, registry, BindingLevel::RepaintOnly);
118 self.is_hovered
119 .bind_to(id, registry, BindingLevel::RepaintOnly);
120 self.is_dragging
121 .bind_to(id, registry, BindingLevel::RepaintOnly);
122 self.is_disabled
123 .bind_to(id, registry, BindingLevel::RepaintOnly);
124 self.focus_origin
125 .bind_to(id, registry, BindingLevel::RepaintOnly);
126 vec![]
127 }
128
129 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
130 let envelope = ctx.theme.shape.focus_ring_offset + ctx.theme.shape.focus_ring_width;
131 let cross = (self.recipe.thumb_diameter + envelope * 2.0).max(MIN_CROSS_SIZE);
132 match self.orientation {
133 SliderOrientation::Horizontal => {
134 let width = proposal.width.unwrap_or(200.0);
135 Size::new(width, cross)
136 }
137 SliderOrientation::Vertical => {
138 let height = proposal.height.unwrap_or(200.0);
139 Size::new(cross, height)
140 }
141 }
142 .into()
143 }
144
145 fn place_children(
146 &self,
147 _bounds: Rect,
148 _proposal: SizeProposal,
149 _children: &mut [WidgetPlacement],
150 _ctx: &LayoutContext,
151 ) {
152 }
153
154 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
155 let colors = &ctx.theme.colors;
156 let shape = &ctx.theme.shape;
157 let track_height = self.recipe.track_height;
158 let thumb_diameter = self.recipe.thumb_diameter;
159 let thumb_radius = thumb_diameter * 0.5;
160 let enabled = !self.is_disabled.get();
161 let t = self.value_normalized.get().clamp(0.0, 1.0);
162
163 let radius = CornerRadius::uniform(track_height * 0.5);
164 let track_color = if enabled {
165 colors.surface_sunken
166 } else {
167 colors.accent_disabled
168 };
169 let fill_color = if enabled {
170 colors.accent
171 } else {
172 colors.text_disabled
173 };
174
175 let (track_rect, fill_rect, thumb_cx, thumb_cy) = match self.orientation {
176 SliderOrientation::Horizontal => {
177 let ty = bounds.y + (bounds.height - track_height) * 0.5;
178 let track = Rect::new(
179 bounds.x + thumb_radius,
180 ty,
181 (bounds.width - thumb_radius * 2.0).max(0.0),
182 track_height,
183 );
184 let usable = track.width;
185 let thumb_pos = track.x + usable * t;
186 let fill_w = (thumb_pos - track.x).max(0.0);
187 let fill = Rect::new(track.x, ty, fill_w, track_height);
188 (track, fill, thumb_pos, bounds.y + bounds.height * 0.5)
189 }
190 SliderOrientation::Vertical => {
191 let tx = bounds.x + (bounds.width - track_height) * 0.5;
192 let track = Rect::new(
193 tx,
194 bounds.y + thumb_radius,
195 track_height,
196 (bounds.height - thumb_radius * 2.0).max(0.0),
197 );
198 let usable = track.height;
199 let thumb_pos = track.y + usable * t;
200 let fill_h = (thumb_pos - track.y).max(0.0);
201 let fill = Rect::new(tx, track.y, track_height, fill_h);
202 (track, fill, bounds.x + bounds.width * 0.5, thumb_pos)
203 }
204 };
205
206 canvas.fill_rounded_rect(track_rect, radius, track_color);
207 if fill_rect.width > 0.0 && fill_rect.height > 0.0 {
208 canvas.fill_rounded_rect(fill_rect, radius, fill_color);
209 }
210
211 if matches!(self.variant, SliderVariant::Discrete)
216 && let Some(n) = self.tick_count
217 && n >= 2
218 {
219 let tick_size = self.recipe.tick_size.max(2.0);
220 let tick_color = if enabled {
221 colors.text_secondary
222 } else {
223 colors.text_disabled
224 };
225 for i in 0..n {
226 let tt = i as f32 / (n - 1) as f32;
227 match self.orientation {
228 SliderOrientation::Horizontal => {
229 let tx = track_rect.x + track_rect.width * tt;
230 let ty = track_rect.y - tick_size - 2.0;
231 let r = Rect::new(tx - tick_size * 0.5, ty, tick_size, tick_size);
232 canvas.fill_rounded_rect(
233 r,
234 CornerRadius::uniform(tick_size * 0.5),
235 tick_color,
236 );
237 }
238 SliderOrientation::Vertical => {
239 let ty = track_rect.y + track_rect.height * tt;
240 let tx = track_rect.x - tick_size - 2.0;
241 let r = Rect::new(tx, ty - tick_size * 0.5, tick_size, tick_size);
242 canvas.fill_rounded_rect(
243 r,
244 CornerRadius::uniform(tick_size * 0.5),
245 tick_color,
246 );
247 }
248 }
249 }
250 }
251
252 let thumb_color = if !enabled {
254 colors.text_disabled
255 } else if self.is_dragging.get() {
256 colors.accent_pressed
257 } else if self.is_hovered.get() {
258 colors.accent_hover
259 } else {
260 colors.accent
261 };
262 let thumb_rect = Rect::new(
263 thumb_cx - thumb_radius,
264 thumb_cy - thumb_radius,
265 thumb_diameter,
266 thumb_diameter,
267 );
268 canvas.fill_rounded_rect(thumb_rect, CornerRadius::uniform(thumb_radius), thumb_color);
269
270 if self.focus_origin.get() == Some(FocusOrigin::Keyboard) {
273 let offset = shape.focus_ring_offset;
274 let half_stroke = shape.focus_ring_width * 0.5;
275 let ring_inset = offset + half_stroke;
276 let ring_rect = Rect::new(
277 thumb_rect.x - ring_inset,
278 thumb_rect.y - ring_inset,
279 thumb_rect.width + ring_inset * 2.0,
280 thumb_rect.height + ring_inset * 2.0,
281 );
282 canvas.stroke_rounded_rect(
283 ring_rect,
284 CornerRadius::uniform(thumb_radius + ring_inset),
285 colors.focus_ring,
286 shape.focus_ring_width,
287 );
288 }
289 }
290
291 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
292 builder.set_hidden();
296 }
297}