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::signal::Signal;
27use teksilo_core::styles::{
28 ScrollBarOrientation, ScrollBarStyle, ScrollBarStyleConfig, ScrollBarVariant,
29};
30use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget};
31use teksilo_core::widget_id::WidgetId;
32use teksilo_tokens::{Color, CornerRadius};
33
34use crate::primitives::ZStack;
35
36pub const SCROLLBAR_THICKNESS_IDLE: f32 = 4.0;
38pub const SCROLLBAR_THICKNESS_HOVER: f32 = 8.0;
39pub const SCROLLBAR_MIN_THUMB_LENGTH: f32 = 24.0;
40pub const SCROLLBAR_CORNER_RADIUS: f32 = 2.0;
41
42const OVERRIDE_THUMB_ALPHA_IDLE: f32 = 0.50;
48const OVERRIDE_THUMB_ALPHA_HOVER: f32 = 0.72;
49const OVERRIDE_THUMB_ALPHA_PRESSED: f32 = 0.92;
50const OVERRIDE_TRACK_ALPHA: f32 = 0.12;
51
52fn resolve_thumb_color(
57 override_color: Option<&ColorProp>,
58 ctx: &PaintContext,
59 hovered: bool,
60 pressed: bool,
61) -> Color {
62 match override_color {
63 Some(prop) => {
64 let alpha = if pressed {
65 OVERRIDE_THUMB_ALPHA_PRESSED
66 } else if hovered {
67 OVERRIDE_THUMB_ALPHA_HOVER
68 } else {
69 OVERRIDE_THUMB_ALPHA_IDLE
70 };
71 prop.resolve(ctx.theme, true).with_alpha(alpha)
72 }
73 None => {
74 let c = &ctx.theme.colors;
75 if pressed {
76 c.scrollbar_thumb_pressed
77 } else if hovered {
78 c.scrollbar_thumb_hover
79 } else {
80 c.scrollbar_thumb
81 }
82 }
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq)]
88pub struct ScrollBarRecipe {
89 pub thickness_idle: f32,
90 pub thickness_hover: f32,
91 pub min_thumb_length: f32,
92 pub corner_radius: f32,
93}
94
95impl Default for ScrollBarRecipe {
96 fn default() -> Self {
97 Self {
98 thickness_idle: SCROLLBAR_THICKNESS_IDLE,
99 thickness_hover: SCROLLBAR_THICKNESS_HOVER,
100 min_thumb_length: SCROLLBAR_MIN_THUMB_LENGTH,
101 corner_radius: SCROLLBAR_CORNER_RADIUS,
102 }
103 }
104}
105
106#[derive(Debug, Default, Clone, Copy)]
109pub struct RecipeScrollBarStyle {
110 pub recipe: ScrollBarRecipe,
111}
112
113impl RecipeScrollBarStyle {
114 pub fn new(recipe: ScrollBarRecipe) -> Self {
115 Self { recipe }
116 }
117}
118
119impl ScrollBarStyle for RecipeScrollBarStyle {
120 fn make_body(&self, cfg: &ScrollBarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
121 let thickness = self.recipe.thickness_hover;
126 let resting_thickness = self.recipe.thickness_idle;
127
128 match cfg.variant {
129 ScrollBarVariant::Permanent => ctx.add(FullBarPainter {
130 orientation: cfg.orientation,
131 thickness,
132 min_thumb_length: cfg.min_thumb_length,
133 scroll_ratio: cfg.scroll_ratio.clone(),
134 viewport_ratio: cfg.viewport_ratio.clone(),
135 is_hovered: cfg.is_hovered.clone(),
136 is_dragging: cfg.is_dragging.clone(),
137 is_idle: cfg.is_idle.clone(),
138 show_track: true,
139 thumb_color: cfg.thumb_color.clone(),
140 }),
141 ScrollBarVariant::Thin => ctx.add(ThinIndicatorPainter {
142 orientation: cfg.orientation,
143 thickness,
144 resting_thickness,
145 min_thumb_length: cfg.min_thumb_length,
146 scroll_ratio: cfg.scroll_ratio.clone(),
147 viewport_ratio: cfg.viewport_ratio.clone(),
148 is_idle: cfg.is_idle.clone(),
149 thumb_color: cfg.thumb_color.clone(),
150 }),
151 ScrollBarVariant::Overlay => {
152 let initial = if cfg.is_hovered.get() || cfg.is_dragging.get() {
164 1.0
165 } else {
166 0.0
167 };
168 let revealed = ctx.animated_signal(initial);
169 let anim = ctx.animate().fast().standard();
170 {
171 let dragging = cfg.is_dragging.clone();
172 let revealed = revealed.clone();
173 let anim = anim.clone();
174 ctx.effect(&cfg.is_hovered, move |hovered| {
175 let target = if *hovered || dragging.get() { 1.0 } else { 0.0 };
176 anim.to_or_snap(&revealed, target);
177 });
178 }
179 {
180 let hovered = cfg.is_hovered.clone();
181 let revealed = revealed.clone();
182 let anim = anim.clone();
183 ctx.effect(&cfg.is_dragging, move |dragging| {
184 let target = if hovered.get() || *dragging { 1.0 } else { 0.0 };
185 anim.to_or_snap(&revealed, target);
186 });
187 }
188 let inactive_opacity = revealed.map(|r| 1.0 - *r);
189
190 let thin = ThinIndicatorPainter {
191 orientation: cfg.orientation,
192 thickness,
193 resting_thickness,
194 min_thumb_length: cfg.min_thumb_length,
195 scroll_ratio: cfg.scroll_ratio.clone(),
196 viewport_ratio: cfg.viewport_ratio.clone(),
197 is_idle: cfg.is_idle.clone(),
198 thumb_color: cfg.thumb_color.clone(),
199 };
200 let full = FullBarPainter {
201 orientation: cfg.orientation,
202 thickness,
203 min_thumb_length: cfg.min_thumb_length,
204 scroll_ratio: cfg.scroll_ratio.clone(),
205 viewport_ratio: cfg.viewport_ratio.clone(),
206 is_hovered: cfg.is_hovered.clone(),
207 is_dragging: cfg.is_dragging.clone(),
208 is_idle: cfg.is_idle.clone(),
209 show_track: false,
210 thumb_color: cfg.thumb_color.clone(),
211 };
212
213 let thin_id = ctx.add(thin);
214 let full_id = ctx.add(full);
215 ctx.set_opacity(thin_id, inactive_opacity);
216 ctx.set_opacity(full_id, revealed);
217 ctx.add(ZStack::new().add_child(thin_id).add_child(full_id))
218 }
219 }
220 }
221}
222
223struct ThinIndicatorPainter {
226 orientation: ScrollBarOrientation,
227 thickness: f32,
230 resting_thickness: f32,
231 min_thumb_length: f32,
232 scroll_ratio: Signal<f32>,
233 viewport_ratio: Signal<f32>,
234 is_idle: Signal<bool>,
235 thumb_color: Option<ColorProp>,
236}
237
238impl std::fmt::Debug for ThinIndicatorPainter {
239 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240 f.debug_struct("ThinIndicatorPainter")
241 .field("orientation", &self.orientation)
242 .finish()
243 }
244}
245
246impl Widget for ThinIndicatorPainter {
247 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
248 let id = ctx.self_id();
249 let registry = ctx.binding_registry();
250 self.scroll_ratio
251 .bind_to(id, registry, BindingLevel::RepaintOnly);
252 self.viewport_ratio
253 .bind_to(id, registry, BindingLevel::RepaintOnly);
254 self.is_idle
255 .bind_to(id, registry, BindingLevel::RepaintOnly);
256 Vec::new()
257 }
258
259 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
260 match self.orientation {
261 ScrollBarOrientation::Vertical => {
262 Size::new(self.thickness, proposal.height.unwrap_or(0.0))
263 }
264 ScrollBarOrientation::Horizontal => {
265 Size::new(proposal.width.unwrap_or(0.0), self.thickness)
266 }
267 }
268 .into()
269 }
270
271 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
272 if self.is_idle.get() {
273 return;
274 }
275 let thin = self.resting_thickness;
276 let thin_bounds = match self.orientation {
277 ScrollBarOrientation::Vertical => {
278 Rect::new(bounds.right() - thin, bounds.y, thin, bounds.height)
279 }
280 ScrollBarOrientation::Horizontal => {
281 Rect::new(bounds.x, bounds.bottom() - thin, bounds.width, thin)
282 }
283 };
284 let track_len = match self.orientation {
285 ScrollBarOrientation::Vertical => bounds.height,
286 ScrollBarOrientation::Horizontal => bounds.width,
287 };
288 let ratio = self.viewport_ratio.get().clamp(0.0, 1.0);
289 let thumb_len = (track_len * ratio)
290 .max(self.min_thumb_length)
291 .min(track_len);
292 let scroll_ratio = self.scroll_ratio.get().clamp(0.0, 1.0);
293 let offset = scroll_ratio * (track_len - thumb_len);
294
295 let radius = CornerRadius::uniform(thin / 2.0);
296 let thumb_rect = match self.orientation {
297 ScrollBarOrientation::Vertical => {
298 Rect::new(thin_bounds.x, thin_bounds.y + offset, thin, thumb_len)
299 }
300 ScrollBarOrientation::Horizontal => {
301 Rect::new(thin_bounds.x + offset, thin_bounds.y, thumb_len, thin)
302 }
303 };
304 let thumb_color = resolve_thumb_color(self.thumb_color.as_ref(), ctx, false, false);
306 canvas.fill_rounded_rect(thumb_rect, radius, thumb_color);
307 }
308
309 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
310 builder.set_hidden();
311 }
312}
313
314struct FullBarPainter {
319 orientation: ScrollBarOrientation,
320 thickness: f32,
321 min_thumb_length: f32,
322 scroll_ratio: Signal<f32>,
323 viewport_ratio: Signal<f32>,
324 is_hovered: Signal<bool>,
325 is_dragging: Signal<bool>,
326 is_idle: Signal<bool>,
327 show_track: bool,
331 thumb_color: Option<ColorProp>,
332}
333
334impl std::fmt::Debug for FullBarPainter {
335 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336 f.debug_struct("FullBarPainter")
337 .field("orientation", &self.orientation)
338 .field("show_track", &self.show_track)
339 .finish()
340 }
341}
342
343impl Widget for FullBarPainter {
344 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
345 let id = ctx.self_id();
346 let registry = ctx.binding_registry();
347 self.scroll_ratio
348 .bind_to(id, registry, BindingLevel::RepaintOnly);
349 self.viewport_ratio
350 .bind_to(id, registry, BindingLevel::RepaintOnly);
351 self.is_hovered
352 .bind_to(id, registry, BindingLevel::RepaintOnly);
353 self.is_dragging
354 .bind_to(id, registry, BindingLevel::RepaintOnly);
355 self.is_idle
356 .bind_to(id, registry, BindingLevel::RepaintOnly);
357 Vec::new()
358 }
359
360 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
361 match self.orientation {
362 ScrollBarOrientation::Vertical => {
363 Size::new(self.thickness, proposal.height.unwrap_or(0.0))
364 }
365 ScrollBarOrientation::Horizontal => {
366 Size::new(proposal.width.unwrap_or(0.0), self.thickness)
367 }
368 }
369 .into()
370 }
371
372 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
373 if self.is_idle.get() {
374 return;
375 }
376 let ratio = self.viewport_ratio.get().clamp(0.0, 1.0);
377 let track_len = match self.orientation {
378 ScrollBarOrientation::Vertical => bounds.height,
379 ScrollBarOrientation::Horizontal => bounds.width,
380 };
381 let thumb_len = (track_len * ratio)
382 .max(self.min_thumb_length)
383 .min(track_len);
384 let scroll_ratio = self.scroll_ratio.get().clamp(0.0, 1.0);
385 let offset = scroll_ratio * (track_len - thumb_len);
386
387 let radius = CornerRadius::uniform(self.thickness / 2.0);
388
389 if self.show_track {
390 let track = match &self.thumb_color {
391 Some(prop) => prop
392 .resolve(ctx.theme, true)
393 .with_alpha(OVERRIDE_TRACK_ALPHA),
394 None => ctx.theme.colors.scrollbar_track_hover,
395 };
396 canvas.fill_rounded_rect(bounds, radius, track);
397 }
398
399 let thumb = match self.orientation {
400 ScrollBarOrientation::Vertical => {
401 Rect::new(bounds.x, bounds.y + offset, bounds.width, thumb_len)
402 }
403 ScrollBarOrientation::Horizontal => {
404 Rect::new(bounds.x + offset, bounds.y, thumb_len, bounds.height)
405 }
406 };
407 let thumb_color = resolve_thumb_color(
408 self.thumb_color.as_ref(),
409 ctx,
410 self.is_hovered.get(),
411 self.is_dragging.get(),
412 );
413 canvas.fill_rounded_rect(thumb, radius, thumb_color);
414 }
415
416 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
417 builder.set_hidden();
418 }
419}