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::environment::LayoutDirection;
22use teksilo_core::focus::FocusOrigin;
23use teksilo_core::signal::Signal;
24use teksilo_core::styles::{
25 FocusRingStroke, SliderOrientation, SliderStyle, SliderStyleConfig, SliderVariant,
26};
27use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
28use teksilo_core::widget_id::WidgetId;
29use teksilo_tokens::{CornerRadius, InputTokens};
30
31const MIN_CROSS_SIZE: f32 = 24.0;
34
35pub const SLIDER_TRACK_HEIGHT: f32 = 4.0;
37pub const SLIDER_THUMB_DIAMETER: f32 = 14.0;
38pub const SLIDER_TICK_SIZE: f32 = 2.0;
39
40#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct SliderRecipe {
43 pub track_height: f32,
44 pub thumb_diameter: f32,
45 pub tick_size: f32,
46}
47
48impl SliderRecipe {
49 pub fn for_tokens(_tokens: &InputTokens) -> Self {
60 Self {
61 track_height: SLIDER_TRACK_HEIGHT,
62 thumb_diameter: SLIDER_THUMB_DIAMETER,
63 tick_size: SLIDER_TICK_SIZE,
64 }
65 }
66}
67
68impl Default for SliderRecipe {
69 fn default() -> Self {
70 Self::for_tokens(&InputTokens::default())
71 }
72}
73
74#[derive(Debug, Default, Clone, Copy)]
77pub struct RecipeSliderStyle {
78 pub recipe: SliderRecipe,
79}
80
81impl RecipeSliderStyle {
82 pub fn new(recipe: SliderRecipe) -> Self {
83 Self { recipe }
84 }
85
86 pub fn for_tokens(tokens: &InputTokens) -> Self {
93 Self {
94 recipe: SliderRecipe::for_tokens(tokens),
95 }
96 }
97}
98
99impl SliderStyle for RecipeSliderStyle {
100 fn thumb_diameter(&self, _cfg: &SliderStyleConfig) -> f32 {
101 self.recipe.thumb_diameter
102 }
103
104 fn make_body(&self, cfg: &SliderStyleConfig, ctx: &mut BuildContext) -> WidgetId {
105 ctx.add(SliderBody {
106 recipe: self.recipe,
107 value_normalized: cfg.value_normalized.clone(),
108 is_hovered: cfg.is_hovered.clone(),
109 is_dragging: cfg.is_dragging.clone(),
110 is_disabled: cfg.is_disabled.clone(),
111 focus_origin: cfg.focus_origin.clone(),
112 orientation: cfg.orientation,
113 tick_count: cfg.tick_count,
114 variant: cfg.variant,
115 })
116 }
117}
118
119struct SliderBody {
123 recipe: SliderRecipe,
124 value_normalized: Signal<f32>,
125 is_hovered: Signal<bool>,
126 is_dragging: Signal<bool>,
127 is_disabled: Signal<bool>,
128 focus_origin: Signal<Option<FocusOrigin>>,
129 orientation: SliderOrientation,
130 tick_count: Option<u32>,
131 variant: SliderVariant,
132}
133
134impl std::fmt::Debug for SliderBody {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 f.debug_struct("SliderBody")
137 .field("orientation", &self.orientation)
138 .field("variant", &self.variant)
139 .finish()
140 }
141}
142
143impl Widget for SliderBody {
144 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
145 let id = ctx.self_id();
146 let registry = ctx.binding_registry();
147 self.value_normalized
148 .bind_to(id, registry, BindingLevel::RepaintOnly);
149 self.is_hovered
150 .bind_to(id, registry, BindingLevel::RepaintOnly);
151 self.is_dragging
152 .bind_to(id, registry, BindingLevel::RepaintOnly);
153 self.is_disabled
154 .bind_to(id, registry, BindingLevel::RepaintOnly);
155 self.focus_origin
156 .bind_to(id, registry, BindingLevel::RepaintOnly);
157 vec![]
158 }
159
160 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
161 let envelope = ctx.theme.shape.focus_ring_envelope();
162 let cross = (self.recipe.thumb_diameter + envelope * 2.0).max(MIN_CROSS_SIZE);
163 match self.orientation {
164 SliderOrientation::Horizontal => {
165 let width = proposal.width.unwrap_or(200.0);
166 Size::new(width, cross)
167 }
168 SliderOrientation::Vertical => {
169 let height = proposal.height.unwrap_or(200.0);
170 Size::new(cross, height)
171 }
172 }
173 .into()
174 }
175
176 fn place_children(
177 &self,
178 _bounds: Rect,
179 _proposal: SizeProposal,
180 _children: &mut [WidgetPlacement],
181 _ctx: &LayoutContext,
182 ) {
183 }
184
185 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
186 let colors = &ctx.theme.colors;
187 let shape = &ctx.theme.shape;
188 let track_height = self.recipe.track_height;
189 let thumb_diameter = self.recipe.thumb_diameter;
190 let thumb_radius = thumb_diameter * 0.5;
191 let enabled = !self.is_disabled.get();
192 let t = self.value_normalized.get().clamp(0.0, 1.0);
193
194 let rtl = ctx.layout_direction == LayoutDirection::RightToLeft;
195
196 let radius = CornerRadius::uniform(track_height * 0.5);
197 let track_color = if enabled {
198 colors.surface_sunken
199 } else {
200 colors.accent_disabled
201 };
202 let fill_color = if enabled {
203 colors.accent
204 } else {
205 colors.text_disabled
206 };
207
208 let (track_rect, fill_rect, thumb_cx, thumb_cy) = match self.orientation {
209 SliderOrientation::Horizontal => {
210 let ty = bounds.y + (bounds.height - track_height) * 0.5;
211 let track = Rect::new(
212 bounds.x + thumb_radius,
213 ty,
214 (bounds.width - thumb_radius * 2.0).max(0.0),
215 track_height,
216 );
217 let usable = track.width;
218 let thumb_pos = if rtl {
226 track.x + usable * (1.0 - t)
227 } else {
228 track.x + usable * t
229 };
230 let fill = if rtl {
231 let fill_w = (track.right() - thumb_pos).max(0.0);
232 Rect::new(thumb_pos, ty, fill_w, track_height)
233 } else {
234 let fill_w = (thumb_pos - track.x).max(0.0);
235 Rect::new(track.x, ty, fill_w, track_height)
236 };
237 (track, fill, thumb_pos, bounds.y + bounds.height * 0.5)
238 }
239 SliderOrientation::Vertical => {
240 let tx = bounds.x + (bounds.width - track_height) * 0.5;
241 let track = Rect::new(
242 tx,
243 bounds.y + thumb_radius,
244 track_height,
245 (bounds.height - thumb_radius * 2.0).max(0.0),
246 );
247 let usable = track.height;
248 let thumb_pos = track.bottom() - usable * t;
256 let fill_h = (track.bottom() - thumb_pos).max(0.0);
257 let fill = Rect::new(tx, thumb_pos, track_height, fill_h);
258 (track, fill, bounds.x + bounds.width * 0.5, thumb_pos)
259 }
260 };
261
262 canvas.fill_rounded_rect(track_rect, radius, track_color);
263 if fill_rect.width > 0.0 && fill_rect.height > 0.0 {
264 canvas.fill_rounded_rect(fill_rect, radius, fill_color);
265 }
266
267 if matches!(self.variant, SliderVariant::Discrete)
272 && let Some(n) = self.tick_count
273 && n >= 2
274 {
275 let tick_size = self.recipe.tick_size.max(2.0);
276 let tick_color = if enabled {
277 colors.text_secondary
278 } else {
279 colors.text_disabled
280 };
281 for i in 0..n {
282 let tt = i as f32 / (n - 1) as f32;
283 match self.orientation {
284 SliderOrientation::Horizontal => {
285 let tt = if rtl { 1.0 - tt } else { tt };
286 let tx = track_rect.x + track_rect.width * tt;
287 let ty = track_rect.y - tick_size - 2.0;
288 let r = Rect::new(tx - tick_size * 0.5, ty, tick_size, tick_size);
289 canvas.fill_rounded_rect(
290 r,
291 CornerRadius::uniform(tick_size * 0.5),
292 tick_color,
293 );
294 }
295 SliderOrientation::Vertical => {
296 let ty = track_rect.y + track_rect.height * tt;
297 let tx = track_rect.x - tick_size - 2.0;
298 let r = Rect::new(tx, ty - tick_size * 0.5, tick_size, tick_size);
299 canvas.fill_rounded_rect(
300 r,
301 CornerRadius::uniform(tick_size * 0.5),
302 tick_color,
303 );
304 }
305 }
306 }
307 }
308
309 let thumb_color = if !enabled {
311 colors.text_disabled
312 } else if self.is_dragging.get() {
313 colors.accent_pressed
314 } else if self.is_hovered.get() {
315 colors.accent_hover
316 } else {
317 colors.accent
318 };
319 let thumb_rect = Rect::new(
320 thumb_cx - thumb_radius,
321 thumb_cy - thumb_radius,
322 thumb_diameter,
323 thumb_diameter,
324 );
325 canvas.fill_rounded_rect(thumb_rect, CornerRadius::uniform(thumb_radius), thumb_color);
326
327 if self.focus_origin.get() == Some(FocusOrigin::Keyboard) {
330 let ring = FocusRingStroke::around(thumb_rect, thumb_radius, shape);
331 canvas.stroke_rounded_rect(
332 ring.rect,
333 CornerRadius::uniform(ring.corner_radius),
334 colors.focus_ring,
335 shape.focus_ring_width,
336 );
337 }
338 }
339
340 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
341 builder.set_hidden();
345 }
346}