1use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
22use teksilo_core::accessibility::AccessNodeBuilder;
23use teksilo_core::binding::BindingLevel;
24use teksilo_core::build_context::BuildContext;
25use teksilo_core::color_prop::ColorProp;
26use teksilo_core::environment::LayoutDirection;
27use teksilo_core::signal::Signal;
28use teksilo_core::styles::{
29 ScrollBarOrientation, ScrollBarStyle, ScrollBarStyleConfig, ScrollBarVariant,
30};
31use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget};
32use teksilo_core::widget_id::WidgetId;
33use teksilo_tokens::{Color, CornerRadius, InputTokens, TargetRole};
34
35use crate::primitives::ZStack;
36use teksilo_core::styles::density::dp;
37
38pub const SCROLLBAR_THICKNESS_IDLE: f32 = 4.0;
40pub const SCROLLBAR_THICKNESS_HOVER: f32 = 8.0;
41pub const SCROLLBAR_MIN_THUMB_LENGTH: f32 = 24.0;
42pub const SCROLLBAR_CORNER_RADIUS: f32 = 2.0;
43
44const OVERRIDE_THUMB_ALPHA_IDLE: f32 = 0.50;
50const OVERRIDE_THUMB_ALPHA_HOVER: f32 = 0.72;
51const OVERRIDE_THUMB_ALPHA_PRESSED: f32 = 0.92;
52const OVERRIDE_TRACK_ALPHA: f32 = 0.12;
53
54fn resolve_thumb_color(
59 override_color: Option<&ColorProp>,
60 ctx: &PaintContext,
61 hovered: bool,
62 pressed: bool,
63) -> Color {
64 match override_color {
65 Some(prop) => {
66 let alpha = if pressed {
67 OVERRIDE_THUMB_ALPHA_PRESSED
68 } else if hovered {
69 OVERRIDE_THUMB_ALPHA_HOVER
70 } else {
71 OVERRIDE_THUMB_ALPHA_IDLE
72 };
73 prop.resolve(ctx.theme, true).with_alpha(alpha)
74 }
75 None => {
76 let c = &ctx.theme.colors;
77 if pressed {
78 c.scrollbar_thumb_pressed
79 } else if hovered {
80 c.scrollbar_thumb_hover
81 } else {
82 c.scrollbar_thumb
83 }
84 }
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq)]
90pub struct ScrollBarRecipe {
91 pub thickness_idle: f32,
92 pub thickness_hover: f32,
93 pub min_thumb_length: f32,
101 pub corner_radius: f32,
102}
103
104impl ScrollBarRecipe {
105 pub fn for_tokens(tokens: &InputTokens) -> Self {
111 Self {
112 thickness_idle: SCROLLBAR_THICKNESS_IDLE,
113 thickness_hover: SCROLLBAR_THICKNESS_HOVER,
114 min_thumb_length: dp(SCROLLBAR_MIN_THUMB_LENGTH, TargetRole::Target, tokens),
115 corner_radius: SCROLLBAR_CORNER_RADIUS,
116 }
117 }
118}
119
120impl Default for ScrollBarRecipe {
121 fn default() -> Self {
122 Self::for_tokens(&InputTokens::default())
123 }
124}
125
126#[derive(Debug, Default, Clone, Copy)]
129pub struct RecipeScrollBarStyle {
130 pub recipe: ScrollBarRecipe,
131}
132
133impl RecipeScrollBarStyle {
134 pub fn new(recipe: ScrollBarRecipe) -> Self {
135 Self { recipe }
136 }
137
138 pub fn for_tokens(tokens: &InputTokens) -> Self {
145 Self {
146 recipe: ScrollBarRecipe::for_tokens(tokens),
147 }
148 }
149}
150
151impl ScrollBarStyle for RecipeScrollBarStyle {
152 fn make_body(&self, cfg: &ScrollBarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
153 let thickness = self.recipe.thickness_hover;
158 let resting_thickness = self.recipe.thickness_idle;
159
160 match cfg.variant {
161 ScrollBarVariant::Permanent => ctx.add(FullBarPainter {
162 orientation: cfg.orientation,
163 thickness,
164 min_thumb_length: cfg.min_thumb_length,
165 scroll_ratio: cfg.scroll_ratio.clone(),
166 viewport_ratio: cfg.viewport_ratio.clone(),
167 is_hovered: cfg.is_hovered.clone(),
168 is_dragging: cfg.is_dragging.clone(),
169 is_idle: cfg.is_idle.clone(),
170 show_track: true,
171 thumb_color: cfg.thumb_color.clone(),
172 }),
173 ScrollBarVariant::Thin => ctx.add(ThinIndicatorPainter {
174 orientation: cfg.orientation,
175 thickness,
176 resting_thickness,
177 min_thumb_length: cfg.min_thumb_length,
178 scroll_ratio: cfg.scroll_ratio.clone(),
179 viewport_ratio: cfg.viewport_ratio.clone(),
180 is_idle: cfg.is_idle.clone(),
181 thumb_color: cfg.thumb_color.clone(),
182 }),
183 ScrollBarVariant::Overlay => {
184 let initial = if cfg.is_hovered.get() || cfg.is_dragging.get() {
196 1.0
197 } else {
198 0.0
199 };
200 let revealed = ctx.animated_signal(initial);
201 let anim = ctx.animate().fast().standard();
202 {
203 let dragging = cfg.is_dragging.clone();
204 let revealed = revealed.clone();
205 let anim = anim.clone();
206 ctx.effect(&cfg.is_hovered, move |hovered| {
207 let target = if *hovered || dragging.get() { 1.0 } else { 0.0 };
208 anim.to_or_snap(&revealed, target);
209 });
210 }
211 {
212 let hovered = cfg.is_hovered.clone();
213 let revealed = revealed.clone();
214 let anim = anim.clone();
215 ctx.effect(&cfg.is_dragging, move |dragging| {
216 let target = if hovered.get() || *dragging { 1.0 } else { 0.0 };
217 anim.to_or_snap(&revealed, target);
218 });
219 }
220 let inactive_opacity = revealed.map(|r| 1.0 - *r);
221
222 let thin = ThinIndicatorPainter {
223 orientation: cfg.orientation,
224 thickness,
225 resting_thickness,
226 min_thumb_length: cfg.min_thumb_length,
227 scroll_ratio: cfg.scroll_ratio.clone(),
228 viewport_ratio: cfg.viewport_ratio.clone(),
229 is_idle: cfg.is_idle.clone(),
230 thumb_color: cfg.thumb_color.clone(),
231 };
232 let full = FullBarPainter {
233 orientation: cfg.orientation,
234 thickness,
235 min_thumb_length: cfg.min_thumb_length,
236 scroll_ratio: cfg.scroll_ratio.clone(),
237 viewport_ratio: cfg.viewport_ratio.clone(),
238 is_hovered: cfg.is_hovered.clone(),
239 is_dragging: cfg.is_dragging.clone(),
240 is_idle: cfg.is_idle.clone(),
241 show_track: false,
242 thumb_color: cfg.thumb_color.clone(),
243 };
244
245 let thin_id = ctx.add(thin);
246 let full_id = ctx.add(full);
247 ctx.set_opacity(thin_id, inactive_opacity);
248 ctx.set_opacity(full_id, revealed);
249 ctx.add(ZStack::new().child(thin_id).child(full_id))
250 }
251 }
252 }
253
254 fn min_thumb_length(&self, _tokens: &InputTokens) -> f32 {
260 self.recipe.min_thumb_length
261 }
262}
263
264struct ThinIndicatorPainter {
267 orientation: ScrollBarOrientation,
268 thickness: f32,
271 resting_thickness: f32,
272 min_thumb_length: f32,
273 scroll_ratio: Signal<f32>,
274 viewport_ratio: Signal<f32>,
275 is_idle: Signal<bool>,
276 thumb_color: Option<ColorProp>,
277}
278
279impl std::fmt::Debug for ThinIndicatorPainter {
280 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281 f.debug_struct("ThinIndicatorPainter")
282 .field("orientation", &self.orientation)
283 .finish()
284 }
285}
286
287fn horizontal_thumb_x(
301 track_x: f32,
302 track_width: f32,
303 offset: f32,
304 thumb_len: f32,
305 ctx: &PaintContext,
306) -> f32 {
307 if ctx.layout_direction == LayoutDirection::RightToLeft {
308 track_x + track_width - offset - thumb_len
309 } else {
310 track_x + offset
311 }
312}
313
314impl Widget for ThinIndicatorPainter {
315 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
316 let id = ctx.self_id();
317 let registry = ctx.binding_registry();
318 self.scroll_ratio
319 .bind_to(id, registry, BindingLevel::RepaintOnly);
320 self.viewport_ratio
321 .bind_to(id, registry, BindingLevel::RepaintOnly);
322 self.is_idle
323 .bind_to(id, registry, BindingLevel::RepaintOnly);
324 Vec::new()
325 }
326
327 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
328 match self.orientation {
329 ScrollBarOrientation::Vertical => {
330 Size::new(self.thickness, proposal.height.unwrap_or(0.0))
331 }
332 ScrollBarOrientation::Horizontal => {
333 Size::new(proposal.width.unwrap_or(0.0), self.thickness)
334 }
335 }
336 .into()
337 }
338
339 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
340 if self.is_idle.get() {
341 return;
342 }
343 let thin = self.resting_thickness;
344 let thin_bounds = match self.orientation {
345 ScrollBarOrientation::Vertical => {
346 Rect::new(bounds.right() - thin, bounds.y, thin, bounds.height)
347 }
348 ScrollBarOrientation::Horizontal => {
349 Rect::new(bounds.x, bounds.bottom() - thin, bounds.width, thin)
350 }
351 };
352 let track_len = match self.orientation {
353 ScrollBarOrientation::Vertical => bounds.height,
354 ScrollBarOrientation::Horizontal => bounds.width,
355 };
356 let ratio = self.viewport_ratio.get().clamp(0.0, 1.0);
357 let thumb_len = (track_len * ratio)
358 .max(self.min_thumb_length)
359 .min(track_len);
360 let scroll_ratio = self.scroll_ratio.get().clamp(0.0, 1.0);
361 let offset = scroll_ratio * (track_len - thumb_len);
362
363 let radius = CornerRadius::uniform(thin / 2.0);
364 let thumb_rect = match self.orientation {
365 ScrollBarOrientation::Vertical => {
366 Rect::new(thin_bounds.x, thin_bounds.y + offset, thin, thumb_len)
367 }
368 ScrollBarOrientation::Horizontal => Rect::new(
369 horizontal_thumb_x(thin_bounds.x, thin_bounds.width, offset, thumb_len, ctx),
370 thin_bounds.y,
371 thumb_len,
372 thin,
373 ),
374 };
375 let thumb_color = resolve_thumb_color(self.thumb_color.as_ref(), ctx, false, false);
377 canvas.fill_rounded_rect(thumb_rect, radius, thumb_color);
378 }
379
380 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
381 builder.set_hidden();
382 }
383}
384
385struct FullBarPainter {
390 orientation: ScrollBarOrientation,
391 thickness: f32,
392 min_thumb_length: f32,
393 scroll_ratio: Signal<f32>,
394 viewport_ratio: Signal<f32>,
395 is_hovered: Signal<bool>,
396 is_dragging: Signal<bool>,
397 is_idle: Signal<bool>,
398 show_track: bool,
402 thumb_color: Option<ColorProp>,
403}
404
405impl std::fmt::Debug for FullBarPainter {
406 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407 f.debug_struct("FullBarPainter")
408 .field("orientation", &self.orientation)
409 .field("show_track", &self.show_track)
410 .finish()
411 }
412}
413
414impl Widget for FullBarPainter {
415 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
416 let id = ctx.self_id();
417 let registry = ctx.binding_registry();
418 self.scroll_ratio
419 .bind_to(id, registry, BindingLevel::RepaintOnly);
420 self.viewport_ratio
421 .bind_to(id, registry, BindingLevel::RepaintOnly);
422 self.is_hovered
423 .bind_to(id, registry, BindingLevel::RepaintOnly);
424 self.is_dragging
425 .bind_to(id, registry, BindingLevel::RepaintOnly);
426 self.is_idle
427 .bind_to(id, registry, BindingLevel::RepaintOnly);
428 Vec::new()
429 }
430
431 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
432 match self.orientation {
433 ScrollBarOrientation::Vertical => {
434 Size::new(self.thickness, proposal.height.unwrap_or(0.0))
435 }
436 ScrollBarOrientation::Horizontal => {
437 Size::new(proposal.width.unwrap_or(0.0), self.thickness)
438 }
439 }
440 .into()
441 }
442
443 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
444 if self.is_idle.get() {
445 return;
446 }
447 let ratio = self.viewport_ratio.get().clamp(0.0, 1.0);
448 let track_len = match self.orientation {
449 ScrollBarOrientation::Vertical => bounds.height,
450 ScrollBarOrientation::Horizontal => bounds.width,
451 };
452 let thumb_len = (track_len * ratio)
453 .max(self.min_thumb_length)
454 .min(track_len);
455 let scroll_ratio = self.scroll_ratio.get().clamp(0.0, 1.0);
456 let offset = scroll_ratio * (track_len - thumb_len);
457
458 let radius = CornerRadius::uniform(self.thickness / 2.0);
459
460 if self.show_track {
461 let track = match &self.thumb_color {
462 Some(prop) => prop
463 .resolve(ctx.theme, true)
464 .with_alpha(OVERRIDE_TRACK_ALPHA),
465 None => ctx.theme.colors.scrollbar_track_hover,
466 };
467 canvas.fill_rounded_rect(bounds, radius, track);
468 }
469
470 let thumb = match self.orientation {
471 ScrollBarOrientation::Vertical => {
472 Rect::new(bounds.x, bounds.y + offset, bounds.width, thumb_len)
473 }
474 ScrollBarOrientation::Horizontal => Rect::new(
475 horizontal_thumb_x(bounds.x, bounds.width, offset, thumb_len, ctx),
476 bounds.y,
477 thumb_len,
478 bounds.height,
479 ),
480 };
481 let thumb_color = resolve_thumb_color(
482 self.thumb_color.as_ref(),
483 ctx,
484 self.is_hovered.get(),
485 self.is_dragging.get(),
486 );
487 canvas.fill_rounded_rect(thumb, radius, thumb_color);
488 }
489
490 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
491 builder.set_hidden();
492 }
493}