tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
1//! `llimphi-widget-scroll` — área de scroll vertical reutilizable.
2//!
3//! Hasta ahora cada app rearmaba el scroll a mano: `App::on_wheel` + un
4//! offset en el `Model` + `clip` + virtualización. Este widget empaqueta
5//! ese patrón en un solo builder, **sin estado propio** (el offset sigue
6//! viviendo en el `Model`, fiel al bucle Elm):
7//!
8//! - **viewport clipeado** de alto fijo (`viewport_len`),
9//! - **contenido desplazado** `-offset` px (overflow recortado),
10//! - **barra de scroll arrastrable** a la derecha (sólo si el contenido
11//! excede el viewport),
12//! - **rueda autocontenida** vía [`View::on_scroll`]: girar la rueda con
13//! el cursor sobre el área emite un `Msg` sin que la app rutee nada por
14//! su `on_wheel` global.
15//!
16//! El caller debe conocer el **alto total del contenido** (`content_len`)
17//! y el **alto visible** (`viewport_len`) — igual que `list`/`grid` ya
18//! piden la ventana visible. Para contenido de filas uniformes es
19//! `n_filas * alto_fila`.
20//!
21//! ## Convención del callback `on_scroll`
22//!
23//! `on_scroll` recibe el **delta en px** a sumar al offset (no el offset
24//! absoluto): tanto la rueda como el arrastre de la barra emiten deltas,
25//! y el caller acumula + clampea en su `update` con [`clamp_offset`]. Es
26//! la misma idea que el `splitter` (el handler de drag se reusa durante
27//! todo el arrastre, así que un offset absoluto capturado se quedaría
28//! viejo; el delta-por-evento siempre es correcto).
29//!
30//! ```ignore
31//! // view:
32//! scroll_y(
33//! model.offset,
34//! model.rows.len() as f32 * ROW_H,
35//! panel_h,
36//! lista_view,
37//! Msg::ScrollBy, // Fn(f32) -> Msg, arg = delta px
38//! &ScrollPalette::default(),
39//! )
40//! // update:
41//! Msg::ScrollBy(d) => {
42//! m.offset = clamp_offset(m.offset + d, content_len, viewport_len);
43//! }
44//! ```
45//!
46//! Para llevar una selección a la vista (teclado), ver [`ensure_visible`];
47//! para scroll suave/inercia, ver [`approach`].
48
49#![forbid(unsafe_code)]
50
51use std::sync::Arc;
52
53use llimphi_ui::llimphi_layout::taffy::{
54 prelude::{auto, length, percent, Position, Rect, Size, Style},
55};
56use llimphi_ui::llimphi_raster::peniko::Color;
57use llimphi_ui::{DragPhase, View};
58
59/// Alto mínimo del thumb en px — para que no desaparezca con contenido
60/// muy largo.
61const MIN_THUMB: f32 = 28.0;
62/// Px de desplazamiento por "línea" de rueda. Aproxima el step de scroll
63/// de un editor (≈3 líneas de texto).
64pub const DEFAULT_LINE_PX: f32 = 48.0;
65/// Ancho de la barra de scroll en px.
66pub const DEFAULT_BAR_WIDTH: f32 = 10.0;
67
68/// Factor de alpha por defecto para el thumb en reposo — tenue moderno
69/// estilo Chromium/Edge/Safari: visible pero discreto. Al hover sobre la
70/// barra recupera alpha completo. `1.0` reproduce el comportamiento
71/// histórico (thumb siempre opaco).
72pub const DEFAULT_THUMB_IDLE_ALPHA: f32 = 0.55;
73
74/// Colores de la barra de scroll.
75#[derive(Debug, Clone, Copy)]
76pub struct ScrollPalette {
77 /// Canal de fondo (track).
78 pub track: Color,
79 /// Pulgar (thumb) en reposo.
80 pub thumb: Color,
81 /// Pulgar al pasar el cursor.
82 pub thumb_hover: Color,
83 /// Ancho de la barra y px por línea de rueda.
84 pub bar_width: f32,
85 pub line_px: f32,
86 /// Multiplicador de alpha aplicado al `thumb` en reposo. `1.0` deja
87 /// el color sin tocar (comportamiento legacy); `≤ 0.0` esconde el
88 /// thumb del todo en reposo. El `hover_fill` no se ve afectado: al
89 /// pasar el cursor sobre la barra el thumb recupera el alpha completo
90 /// del `thumb_hover`.
91 pub thumb_idle_alpha: f32,
92}
93
94impl Default for ScrollPalette {
95 fn default() -> Self {
96 Self::from_theme(&llimphi_theme::Theme::dark())
97 }
98}
99
100impl ScrollPalette {
101 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
102 Self {
103 track: t.bg_panel_alt,
104 thumb: t.border,
105 thumb_hover: t.accent,
106 bar_width: DEFAULT_BAR_WIDTH,
107 line_px: DEFAULT_LINE_PX,
108 thumb_idle_alpha: DEFAULT_THUMB_IDLE_ALPHA,
109 }
110 }
111
112 /// Devuelve la `ScrollPalette` con el comportamiento histórico (thumb
113 /// opaco en reposo, sin auto-hide visual). Para apps que dependen del
114 /// look anterior al cambio del 2026-06-07.
115 pub fn opaque(mut self) -> Self {
116 self.thumb_idle_alpha = 1.0;
117 self
118 }
119}
120
121/// Máximo offset posible: cuánto se puede desplazar antes de que el final
122/// del contenido toque el borde inferior del viewport. `0` si el contenido
123/// entra entero.
124pub fn max_offset(content_len: f32, viewport_len: f32) -> f32 {
125 (content_len - viewport_len).max(0.0)
126}
127
128/// Acota `offset` a `[0, max_offset]`. El caller lo usa en su `update`
129/// tras sumar el delta de [`scroll_y`].
130pub fn clamp_offset(offset: f32, content_len: f32, viewport_len: f32) -> f32 {
131 offset.clamp(0.0, max_offset(content_len, viewport_len))
132}
133
134/// Devuelve el offset que deja **visible** el intervalo vertical
135/// `[item_top, item_top + item_h]` dentro de un viewport de alto
136/// `viewport_len`, partiendo de `offset`. Si ya está visible, lo devuelve
137/// sin cambios. Pensado para "llevar la selección a la vista" al navegar
138/// con teclado (flechas, Page Up/Down). El resultado se acota a `≥ 0`; el
139/// caller puede clampear arriba con [`clamp_offset`] si lo necesita.
140pub fn ensure_visible(offset: f32, viewport_len: f32, item_top: f32, item_h: f32) -> f32 {
141 if item_top < offset {
142 // El item arranca por encima del viewport: subí hasta su tope.
143 item_top.max(0.0)
144 } else if item_top + item_h > offset + viewport_len {
145 // El item termina por debajo: bajá hasta que su fondo toque el borde.
146 (item_top + item_h - viewport_len).max(0.0)
147 } else {
148 offset
149 }
150}
151
152/// Un paso de aproximación exponencial de `current` hacia `target`
153/// (scroll suave / inercia). `factor ∈ (0, 1]`: 1.0 salta de una, 0.2
154/// desliza suave. Cuando la diferencia cae por debajo de 0.5 px aterriza
155/// exacto en `target` (evita el "casi-llega" infinito). El caller lo
156/// dispara por frame vía `Handle::spawn_periodic` guardando `target` en
157/// su `Model`.
158pub fn approach(current: f32, target: f32, factor: f32) -> f32 {
159 let f = factor.clamp(0.0, 1.0);
160 let next = current + (target - current) * f;
161 if (target - next).abs() < 0.5 {
162 target
163 } else {
164 next
165 }
166}
167
168/// Velocidad (px/s) por debajo de la cual una inercia se considera detenida.
169pub const FLING_STOP: f32 = 8.0;
170/// Fricción por defecto del fling: fracción de velocidad que **sobrevive por
171/// segundo** (más chico = frena antes). 0.0015 ≈ deslizamiento tipo lista
172/// táctil; subilo (p. ej. 0.1) para frenar rápido.
173pub const FLING_FRICTION: f32 = 0.0015;
174
175/// Un paso de **inercia** (fling): dado `velocity` en px/s y `dt` en segundos,
176/// devuelve `(nueva_velocidad, delta_offset)` bajo decaimiento exponencial
177/// `v(t) = v·friction^t`. `friction ∈ (0,1]` es la fracción de velocidad que
178/// sobrevive por segundo. El `delta` es la integral exacta de la velocidad
179/// sobre el paso (no el rectángulo `v·dt`), así el frenado no depende del
180/// frame-rate. El caller suma `delta` al offset (clampeando con
181/// [`clamp_offset`]) y reusa `nueva_velocidad` el próximo frame hasta que
182/// [`fling_settled`] dé `true`. Es el análogo de [`approach`] pero para
183/// "soltar con envión" en vez de "ir hacia un objetivo".
184pub fn fling_step(velocity: f32, dt: f32, friction: f32) -> (f32, f32) {
185 let f = friction.clamp(1e-6, 1.0);
186 let decay = f.powf(dt.max(0.0));
187 let new_v = velocity * decay;
188 let delta = if (f - 1.0).abs() < 1e-6 {
189 velocity * dt
190 } else {
191 // ∫₀^dt v·f^s ds = v·(f^dt − 1)/ln f.
192 velocity * (decay - 1.0) / f.ln()
193 };
194 (new_v, delta)
195}
196
197/// ¿La inercia ya se detuvo? `true` cuando `|velocity| < FLING_STOP` — el
198/// caller corta el ticker y deja el offset quieto.
199pub fn fling_settled(velocity: f32) -> bool {
200 velocity.abs() < FLING_STOP
201}
202
203/// Resistencia elástica (rubber-band) al **sobrepasar un borde**, estilo iOS:
204/// dado cuánto se pasó del límite (`overscroll`, px; el signo se conserva) y la
205/// dimensión del viewport (`dim`), devuelve el desplazamiento visual
206/// **amortiguado** — siempre menor en magnitud que `overscroll`, con
207/// rendimiento decreciente cuanto más se estira. El caller lo usa para pintar
208/// el contenido un poco más allá del tope mientras arrastra, y lo libera
209/// (anima a 0 con [`approach`]) al soltar. Constante 0.55 = la de Apple.
210pub fn rubber_band(overscroll: f32, dim: f32) -> f32 {
211 if dim <= 0.0 || overscroll == 0.0 {
212 return overscroll;
213 }
214 const C: f32 = 0.55;
215 let x = overscroll.abs();
216 (1.0 - 1.0 / (x * C / dim + 1.0)) * dim * overscroll.signum()
217}
218
219// ── Auto-hide del thumb (timer-driven, lo maneja la app) ──
220
221/// Segundos que el thumb queda a opacidad plena tras la última interacción de
222/// scroll antes de empezar a desvanecerse.
223pub const THUMB_HOLD_SECS: f32 = 1.2;
224/// Segundos que tarda el thumb en desvanecerse de pleno a invisible.
225pub const THUMB_FADE_SECS: f32 = 0.4;
226
227/// Opacidad del thumb en función de los segundos desde la última interacción
228/// de scroll (auto-hide estilo overlay móvil/Chromium): pleno (`1.0`) durante
229/// [`THUMB_HOLD_SECS`], luego baja linealmente a `0.0` en [`THUMB_FADE_SECS`].
230///
231/// El bucle Elm reconstruye el `View` sin estado retenido, así que el timer lo
232/// lleva la app (igual que el fling): trackeá `last_scroll: Instant` en el
233/// Model, reseteándolo en cada `on_scroll`, y antes de llamar a
234/// `scroll_y`/`scroll_xy` hacé `palette.thumb_idle_alpha =
235/// thumb_autohide_alpha(last_scroll.elapsed().as_secs_f32())`. Mientras
236/// [`thumb_autohide_active`] sea `true`, pedí frames (`Handle::spawn_periodic`).
237/// En reposo el thumb queda invisible pero su `hover_fill` lo revela al pasar
238/// el cursor por el borde.
239pub fn thumb_autohide_alpha(secs_since_scroll: f32) -> f32 {
240 if secs_since_scroll <= THUMB_HOLD_SECS {
241 return 1.0;
242 }
243 let fade = (secs_since_scroll - THUMB_HOLD_SECS) / THUMB_FADE_SECS.max(1e-3);
244 (1.0 - fade).clamp(0.0, 1.0)
245}
246
247/// `true` mientras el thumb sigue desvaneciéndose (la app debe seguir pidiendo
248/// frames). Una vez invisible (pasado hold+fade), devuelve `false` y la app
249/// puede dejar de tickear.
250pub fn thumb_autohide_active(secs_since_scroll: f32) -> bool {
251 secs_since_scroll < THUMB_HOLD_SECS + THUMB_FADE_SECS
252}
253
254// ── Pull-to-refresh (compone sobre el overscroll que ya maneja la app) ──
255
256/// Distancia de overscroll (px) a la que el pull-to-refresh queda **armado**
257/// por defecto: soltar más allá dispara el refresh.
258pub const DEFAULT_PULL_THRESHOLD: f32 = 64.0;
259
260/// Progreso del gesto pull-to-refresh en `[0,1]`: qué fracción del umbral
261/// cubre el `overscroll` actual en el tope (distancia que el contenido fue
262/// arrastrado más allá del borde superior — la app ya la computa para el
263/// [`rubber_band`]). Alimenta el barrido de [`pull_indicator_view`].
264pub fn pull_progress(overscroll_px: f32, threshold_px: f32) -> f32 {
265 if threshold_px <= 0.0 {
266 return 0.0;
267 }
268 (overscroll_px / threshold_px).clamp(0.0, 1.0)
269}
270
271/// `true` si el overscroll alcanzó el umbral — soltar en este punto dispara el
272/// refresh. La app lo chequea en `DragPhase::End`/al asentar el rubber-band.
273pub fn pull_triggered(overscroll_px: f32, threshold_px: f32) -> bool {
274 threshold_px > 0.0 && overscroll_px >= threshold_px
275}
276
277// ── Slivers: app-bar colapsable + sticky headers (seam "extent-por-offset") ──
278
279/// Altura de un **app-bar colapsable** dado el `offset` de scroll: arranca en
280/// `header_max` (offset 0) y baja linealmente hasta `header_min`, donde queda
281/// fijado (pinned). El "rango de colapso" es `header_max - header_min`.
282pub fn collapsed_height(offset: f32, header_max: f32, header_min: f32) -> f32 {
283 (header_max - offset.max(0.0)).clamp(header_min, header_max)
284}
285
286/// Fracción de colapso del app-bar en `[0, 1]`: `0` = expandido (offset 0),
287/// `1` = colapsado al mínimo. El caller la usa para fundir el título, achicar
288/// un subtítulo, bajar la opacidad de una imagen de fondo, etc.
289pub fn collapse_fraction(offset: f32, header_max: f32, header_min: f32) -> f32 {
290 let range = (header_max - header_min).max(0.0);
291 if range <= 0.0 {
292 return 1.0;
293 }
294 (offset.max(0.0) / range).clamp(0.0, 1.0)
295}
296
297/// Offset máximo de scroll con un app-bar colapsable: los `header_max -
298/// header_min` px que consume el colapso **más** lo que scrollee el cuerpo
299/// bajo el header ya fijado en `header_min`. El caller lo usa para clampear.
300pub fn sliver_max_offset(
301 content_len: f32,
302 viewport_len: f32,
303 header_max: f32,
304 header_min: f32,
305) -> f32 {
306 let range = (header_max - header_min).max(0.0);
307 let body_vp = (viewport_len - header_min).max(0.0);
308 range + max_offset(content_len, body_vp)
309}
310
311/// Posición `y` (relativa al tope del viewport) de un encabezado **sticky** de
312/// una sección que ocupa `[section_top, section_top + section_h]` en
313/// coordenadas de contenido, con altura de encabezado `header_h`. Mientras la
314/// sección está en pantalla, el encabezado se **pega al tope** (`y = 0`); al
315/// llegar la próxima sección, ésta lo **empuja** hacia arriba (no pasa de
316/// `section_bottom - header_h`). Antes de que la sección llegue al tope, sigue
317/// su posición natural. El caller posiciona el encabezado absoluto en esta `y`.
318pub fn sticky_y(offset: f32, section_top: f32, section_h: f32, header_h: f32) -> f32 {
319 let natural = section_top - offset; // y del encabezado sin sticky
320 let section_bottom = section_top + section_h - offset;
321 natural.max(0.0).min(section_bottom - header_h)
322}
323
324/// Geometría del thumb: `(altura, posición_y)` dentro del track de alto
325/// `viewport_len`, y `offset_por_px` (cuánto offset de contenido equivale
326/// a 1 px de arrastre del thumb). Público para tests y para callers que
327/// quieran pintar su propia barra.
328pub fn thumb_geometry(offset: f32, content_len: f32, viewport_len: f32) -> (f32, f32, f32) {
329 let max_off = max_offset(content_len, viewport_len);
330 if max_off <= 0.0 || content_len <= 0.0 {
331 return (viewport_len, 0.0, 0.0);
332 }
333 let ratio = (viewport_len / content_len).clamp(0.0, 1.0);
334 let thumb_h = (viewport_len * ratio).clamp(MIN_THUMB.min(viewport_len), viewport_len);
335 let travel = (viewport_len - thumb_h).max(0.0);
336 let thumb_y = if max_off > 0.0 {
337 (offset / max_off).clamp(0.0, 1.0) * travel
338 } else {
339 0.0
340 };
341 let offset_per_px = if travel > 0.0 { max_off / travel } else { 0.0 };
342 (thumb_h, thumb_y, offset_per_px)
343}
344
345/// Área de scroll vertical. `offset` es el desplazamiento actual (px, ya
346/// clampeado por el caller). `content_len`/`viewport_len` el alto total y
347/// visible. `content` se desplaza `-offset` y se recorta al viewport.
348/// `on_scroll(delta_px)` se invoca con el delta a sumar al offset (rueda
349/// y arrastre de barra); el caller acumula con [`clamp_offset`].
350pub fn scroll_y<Msg, F>(
351 offset: f32,
352 content_len: f32,
353 viewport_len: f32,
354 content: View<Msg>,
355 on_scroll: F,
356 palette: &ScrollPalette,
357) -> View<Msg>
358where
359 // `Msg` no necesita `Send + Sync`: los closures de rueda/arrastre
360 // capturan el `Arc<dyn Fn + Send + Sync>`, no un `Msg`. Sólo se exige
361 // `Clone` (para montar el `View`) y `'static`.
362 Msg: Clone + 'static,
363 F: Fn(f32) -> Msg + Send + Sync + 'static,
364{
365 let on_scroll = Arc::new(on_scroll);
366
367 // Contenido desplazado: nodo absoluto anclado a left/right (toma el
368 // ancho del viewport) con top = -offset y alto natural. El overflow se
369 // recorta por el `clip` del viewport.
370 let content_wrap = View::new(Style {
371 position: Position::Absolute,
372 inset: Rect {
373 top: length(-offset),
374 left: length(0.0),
375 right: length(0.0),
376 bottom: auto(),
377 },
378 ..Default::default()
379 })
380 .children(vec![content]);
381
382 let mut children = vec![content_wrap];
383
384 // Barra: sólo si hay overflow.
385 if max_offset(content_len, viewport_len) > 0.0 {
386 let (thumb_h, thumb_y, offset_per_px) =
387 thumb_geometry(offset, content_len, viewport_len);
388
389 let on_thumb = on_scroll.clone();
390 let thumb = View::new(Style {
391 position: Position::Absolute,
392 inset: Rect {
393 top: length(thumb_y),
394 right: length(0.0),
395 left: auto(),
396 bottom: auto(),
397 },
398 size: Size {
399 width: length(palette.bar_width),
400 height: length(thumb_h),
401 },
402 ..Default::default()
403 })
404 .fill(palette.thumb.multiply_alpha(palette.thumb_idle_alpha.clamp(0.0, 1.0)))
405 .hover_fill(palette.thumb_hover)
406 .radius((palette.bar_width * 0.5) as f64)
407 .draggable(move |phase, _dx, dy| match phase {
408 // Cada Move trae el delta de px de pantalla del thumb; lo
409 // convertimos a delta de offset de contenido.
410 DragPhase::Move => Some((on_thumb)(dy * offset_per_px)),
411 DragPhase::End => None,
412 });
413
414 let track = View::new(Style {
415 position: Position::Absolute,
416 inset: Rect {
417 top: length(0.0),
418 right: length(0.0),
419 bottom: length(0.0),
420 left: auto(),
421 },
422 size: Size {
423 width: length(palette.bar_width),
424 height: auto(),
425 },
426 ..Default::default()
427 })
428 .fill(palette.track)
429 .children(vec![thumb]);
430
431 children.push(track);
432 }
433
434 // Viewport: alto fijo, ancho del padre, contenido recortado, rueda
435 // local. Position::Relative para ser el bloque contenedor de los
436 // hijos absolutos.
437 //
438 // **Scroll anidado**: si el delta del eje vertical es hacia un extremo
439 // donde ya estamos topados (offset = 0 con dy<0, u offset = max con
440 // dy>0), devolvemos `None` para que el runtime propague el evento al
441 // ancestro scrollable más cercano (lista dentro de panel, etc.).
442 let line_px = palette.line_px;
443 let on_wheel = on_scroll;
444 let max_off = max_offset(content_len, viewport_len);
445 let at_top = offset <= 0.0;
446 let at_bottom = offset >= max_off;
447 View::new(Style {
448 position: Position::Relative,
449 size: Size {
450 width: percent(1.0),
451 height: length(viewport_len),
452 },
453 ..Default::default()
454 })
455 .clip(true)
456 .on_scroll(move |_dx, dy| {
457 let delta = dy * line_px;
458 if (delta < 0.0 && at_top) || (delta > 0.0 && at_bottom) {
459 return None;
460 }
461 Some((on_wheel)(delta))
462 })
463 .children(children)
464}
465
466/// Área de scroll **2D** (horizontal + vertical). Generaliza [`scroll_y`] a dos
467/// ejes: el contenido toma su tamaño natural y se desplaza `(-x, -y)`, recortado
468/// al viewport; aparece una barra por eje que tenga overflow (ninguna, una o
469/// las dos). Para scroll puramente horizontal, pasá `content_size.1 ==
470/// viewport_size.1` (no sale barra vertical).
471///
472/// `on_scroll(dx, dy)` recibe el **delta en px por eje** a sumar a cada offset
473/// (rueda → ambos ejes; arrastre de la barra vertical → sólo `dy`; horizontal →
474/// sólo `dx`). El caller acumula y clampea cada eje con [`clamp_offset`]. Las
475/// dos barras se solapan en una esquinita inferior-derecha (v1; cosmético).
476pub fn scroll_xy<Msg, F>(
477 offset: (f32, f32),
478 content_size: (f32, f32),
479 viewport_size: (f32, f32),
480 content: View<Msg>,
481 on_scroll: F,
482 palette: &ScrollPalette,
483) -> View<Msg>
484where
485 Msg: Clone + 'static,
486 F: Fn(f32, f32) -> Msg + Send + Sync + 'static,
487{
488 let (ox, oy) = offset;
489 let (cw, ch) = content_size;
490 let (vw, vh) = viewport_size;
491 let on_scroll = Arc::new(on_scroll);
492
493 // Contenido a tamaño natural, desplazado (-x, -y). right/bottom = auto para
494 // que no lo achique el viewport (a diferencia de scroll_y, que ancla
495 // left/right para tomar el ancho del viewport).
496 let content_wrap = View::new(Style {
497 position: Position::Absolute,
498 inset: Rect {
499 top: length(-oy),
500 left: length(-ox),
501 right: auto(),
502 bottom: auto(),
503 },
504 ..Default::default()
505 })
506 .children(vec![content]);
507
508 let mut children = vec![content_wrap];
509
510 // Barra vertical (borde derecho) — sólo si hay overflow vertical.
511 if max_offset(ch, vh) > 0.0 {
512 let (thumb_h, thumb_y, opp) = thumb_geometry(oy, ch, vh);
513 let f = on_scroll.clone();
514 let thumb = View::new(Style {
515 position: Position::Absolute,
516 inset: Rect { top: length(thumb_y), right: length(0.0), left: auto(), bottom: auto() },
517 size: Size { width: length(palette.bar_width), height: length(thumb_h) },
518 ..Default::default()
519 })
520 .fill(palette.thumb.multiply_alpha(palette.thumb_idle_alpha.clamp(0.0, 1.0)))
521 .hover_fill(palette.thumb_hover)
522 .radius((palette.bar_width * 0.5) as f64)
523 .draggable(move |phase, _dx, dy| match phase {
524 DragPhase::Move => Some((f)(0.0, dy * opp)),
525 DragPhase::End => None,
526 });
527 let track = View::new(Style {
528 position: Position::Absolute,
529 inset: Rect { top: length(0.0), right: length(0.0), bottom: length(0.0), left: auto() },
530 size: Size { width: length(palette.bar_width), height: auto() },
531 ..Default::default()
532 })
533 .fill(palette.track)
534 .children(vec![thumb]);
535 children.push(track);
536 }
537
538 // Barra horizontal (borde inferior) — sólo si hay overflow horizontal.
539 if max_offset(cw, vw) > 0.0 {
540 let (thumb_w, thumb_x, opp) = thumb_geometry(ox, cw, vw);
541 let f = on_scroll.clone();
542 let thumb = View::new(Style {
543 position: Position::Absolute,
544 inset: Rect { left: length(thumb_x), bottom: length(0.0), top: auto(), right: auto() },
545 size: Size { width: length(thumb_w), height: length(palette.bar_width) },
546 ..Default::default()
547 })
548 .fill(palette.thumb.multiply_alpha(palette.thumb_idle_alpha.clamp(0.0, 1.0)))
549 .hover_fill(palette.thumb_hover)
550 .radius((palette.bar_width * 0.5) as f64)
551 .draggable(move |phase, dx, _dy| match phase {
552 DragPhase::Move => Some((f)(dx * opp, 0.0)),
553 DragPhase::End => None,
554 });
555 let track = View::new(Style {
556 position: Position::Absolute,
557 inset: Rect { left: length(0.0), right: length(0.0), bottom: length(0.0), top: auto() },
558 size: Size { width: auto(), height: length(palette.bar_width) },
559 ..Default::default()
560 })
561 .fill(palette.track)
562 .children(vec![thumb]);
563 children.push(track);
564 }
565
566 // Scroll anidado 2D: si el delta NETO está bloqueado en ambos ejes
567 // (cada componente cae en un extremo del eje correspondiente),
568 // devolvemos `None` para propagar al ancestro scrollable. Si al menos
569 // un eje aún tiene recorrido, el evento se consume entero (como antes).
570 let line_px = palette.line_px;
571 let on_wheel = on_scroll;
572 let max_ox = max_offset(cw, vw);
573 let max_oy = max_offset(ch, vh);
574 let at_left = ox <= 0.0;
575 let at_right = ox >= max_ox;
576 let at_top = oy <= 0.0;
577 let at_bottom = oy >= max_oy;
578 View::new(Style {
579 position: Position::Relative,
580 size: Size { width: length(vw), height: length(vh) },
581 ..Default::default()
582 })
583 .clip(true)
584 // Rueda: dy = eje vertical; dx = eje horizontal (ratones/touchpads 2D, o
585 // Shift+rueda en algunos backends). Ambos en px-línea.
586 .on_scroll(move |dx, dy| {
587 let ddx = dx * line_px;
588 let ddy = dy * line_px;
589 let x_blocked = (ddx < 0.0 && at_left)
590 || (ddx > 0.0 && at_right)
591 || ddx == 0.0;
592 let y_blocked = (ddy < 0.0 && at_top)
593 || (ddy > 0.0 && at_bottom)
594 || ddy == 0.0;
595 if x_blocked && y_blocked {
596 return None;
597 }
598 Some((on_wheel)(ddx, ddy))
599 })
600 .children(children)
601}
602
603/// Indicador circular del **pull-to-refresh**. Mientras el usuario arrastra
604/// más allá del tope, un arco se completa con `progress` (0→1, de
605/// [`pull_progress`]); al alcanzar 1 el círculo queda cerrado ("armado"). Con
606/// `refreshing = true` gira como spinner (arco de 270° rotando por reloj
607/// absoluto — pedí frames mientras dure). `size_px` es el diámetro.
608///
609/// La app lo posiciona en la zona de overscroll del tope (típico: centrado
610/// arriba, bajando junto con el contenido arrastrado). Es puro paint; no
611/// retiene estado.
612pub fn pull_indicator_view<Msg: Clone + 'static>(
613 progress: f32,
614 refreshing: bool,
615 size_px: f32,
616 palette: &ScrollPalette,
617) -> View<Msg> {
618 let p = progress.clamp(0.0, 1.0);
619 let arc_color = palette.thumb;
620 let track_color = palette.track;
621 let started = std::time::Instant::now();
622 View::new(Style {
623 size: Size {
624 width: length(size_px),
625 height: length(size_px),
626 },
627 flex_shrink: 0.0,
628 ..Default::default()
629 })
630 .paint_with(move |scene, _ts, rect| {
631 use llimphi_ui::llimphi_raster::kurbo::{Affine, Arc, Point, Shape, Stroke, Vec2};
632 use std::f64::consts::TAU;
633
634 if rect.w <= 0.0 || rect.h <= 0.0 {
635 return;
636 }
637 let d = (rect.w.min(rect.h)) as f64;
638 let stroke_w = (d * 0.1).clamp(1.5, 4.0);
639 let r = d * 0.5 - stroke_w;
640 if r <= 0.0 {
641 return;
642 }
643 let cx = (rect.x + rect.w * 0.5) as f64;
644 let cy = (rect.y + rect.h * 0.5) as f64;
645 let center = Point::new(cx, cy);
646 let radii = Vec2::new(r, r);
647 let stroke = Stroke::new(stroke_w);
648
649 // Anillo de fondo tenue (track).
650 let ring = Arc::new(center, radii, 0.0, TAU, 0.0).to_path(0.2);
651 scene.stroke(&stroke, Affine::IDENTITY, track_color, None, &ring);
652
653 // Arco activo: arranca arriba (12 en punto = -PI/2). Si refresca, gira
654 // un arco de 270°; si no, barre proporcional al progreso.
655 let top = -std::f64::consts::FRAC_PI_2;
656 let (start, sweep) = if refreshing {
657 let spin = started.elapsed().as_secs_f64() * 3.0; // rad/s
658 (top + spin, TAU * 0.75)
659 } else {
660 (top, TAU * p as f64)
661 };
662 if sweep.abs() > 1e-3 {
663 let active = Arc::new(center, radii, start, sweep, 0.0).to_path(0.2);
664 scene.stroke(&stroke, Affine::IDENTITY, arc_color, None, &active);
665 }
666 })
667}
668
669/// **App-bar colapsable + cuerpo scrolleable** en un solo viewport (el sliver
670/// más pedido). Un único `offset` (en el Model) maneja las dos cosas: primero
671/// **colapsa** el header de `header_max` a `header_min` (consume los primeros
672/// `header_max - header_min` px de scroll), y luego **scrollea** el cuerpo bajo
673/// el header ya fijado en `header_min`.
674///
675/// `header(frac)` construye el contenido del header dado `frac ∈ [0,1]` (ver
676/// [`collapse_fraction`]) — el caller lo usa para fundir el título, mostrar una
677/// versión compacta al colapsar, etc. El header se pinta a la altura
678/// [`collapsed_height`] del momento.
679///
680/// `content_len` es el alto natural del cuerpo; el viewport del cuerpo cambia
681/// con el colapso (crece a medida que el header se achica). La rueda funciona
682/// tanto sobre el header como sobre el cuerpo (ambos emiten `on_scroll`). El
683/// caller clampea el offset con [`sliver_max_offset`].
684pub fn sliver_app_bar<Msg, H, F>(
685 offset: f32,
686 header_max: f32,
687 header_min: f32,
688 header: H,
689 content: View<Msg>,
690 content_len: f32,
691 viewport_len: f32,
692 on_scroll: F,
693 palette: &ScrollPalette,
694) -> View<Msg>
695where
696 Msg: Clone + 'static,
697 H: FnOnce(f32) -> View<Msg>,
698 F: Fn(f32) -> Msg + Send + Sync + 'static,
699{
700 let range = (header_max - header_min).max(0.0);
701 let h = collapsed_height(offset, header_max, header_min);
702 let frac = collapse_fraction(offset, header_max, header_min);
703 // El cuerpo recién empieza a scrollear cuando el colapso terminó.
704 let body_offset = (offset - range).max(0.0);
705 let body_vp = (viewport_len - h).max(0.0);
706
707 let on_scroll = Arc::new(on_scroll);
708 let line_px = palette.line_px;
709
710 // Header pinned (altura `h`), recortado, con rueda propia.
711 let s_head = on_scroll.clone();
712 let header_box = View::new(Style {
713 size: Size { width: percent(1.0), height: length(h) },
714 ..Default::default()
715 })
716 .clip(true)
717 .on_scroll(move |_dx, dy| Some((s_head)(dy * line_px)))
718 .children(vec![header(frac)]);
719
720 // Cuerpo: reusa scroll_y con el viewport restante y el offset del cuerpo.
721 let s_body = on_scroll;
722 let body = scroll_y(
723 body_offset,
724 content_len,
725 body_vp,
726 content,
727 move |d| (s_body)(d),
728 palette,
729 );
730
731 View::new(Style {
732 flex_direction:
733 llimphi_ui::llimphi_layout::taffy::prelude::FlexDirection::Column,
734 size: Size { width: percent(1.0), height: length(viewport_len) },
735 ..Default::default()
736 })
737 .clip(true)
738 .children(vec![header_box, body])
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744
745 #[test]
746 fn thumb_autohide_hold_fade_y_oculto() {
747 // Pleno durante el hold.
748 assert_eq!(thumb_autohide_alpha(0.0), 1.0);
749 assert_eq!(thumb_autohide_alpha(THUMB_HOLD_SECS), 1.0);
750 // A mitad del fade, alpha intermedio.
751 let mid = thumb_autohide_alpha(THUMB_HOLD_SECS + THUMB_FADE_SECS * 0.5);
752 assert!(mid > 0.0 && mid < 1.0, "alpha intermedio: {mid}");
753 // Pasado hold+fade, invisible y la app puede dejar de tickear.
754 assert_eq!(thumb_autohide_alpha(THUMB_HOLD_SECS + THUMB_FADE_SECS + 0.1), 0.0);
755 assert!(thumb_autohide_active(THUMB_HOLD_SECS + THUMB_FADE_SECS * 0.5));
756 assert!(!thumb_autohide_active(THUMB_HOLD_SECS + THUMB_FADE_SECS + 0.1));
757 }
758
759 #[test]
760 fn pull_progress_y_trigger() {
761 assert_eq!(pull_progress(0.0, 64.0), 0.0);
762 assert_eq!(pull_progress(32.0, 64.0), 0.5);
763 assert_eq!(pull_progress(128.0, 64.0), 1.0); // satura
764 assert_eq!(pull_progress(10.0, 0.0), 0.0); // umbral inválido
765 assert!(!pull_triggered(63.9, 64.0));
766 assert!(pull_triggered(64.0, 64.0));
767 assert!(pull_triggered(100.0, 64.0));
768 }
769
770 #[test]
771 fn max_y_clamp() {
772 assert_eq!(max_offset(1000.0, 300.0), 700.0);
773 assert_eq!(max_offset(200.0, 300.0), 0.0); // entra entero
774 assert_eq!(clamp_offset(-50.0, 1000.0, 300.0), 0.0);
775 assert_eq!(clamp_offset(9999.0, 1000.0, 300.0), 700.0);
776 assert_eq!(clamp_offset(400.0, 1000.0, 300.0), 400.0);
777 }
778
779 #[test]
780 fn ensure_visible_arriba_abajo_y_sin_cambio() {
781 let vp = 300.0;
782 // Item por encima del offset → subir hasta su tope.
783 assert_eq!(ensure_visible(500.0, vp, 100.0, 20.0), 100.0);
784 // Item por debajo del fondo visible → bajar lo justo.
785 assert_eq!(ensure_visible(0.0, vp, 400.0, 20.0), 120.0); // 400+20-300
786 // Item ya visible → sin cambios.
787 assert_eq!(ensure_visible(50.0, vp, 100.0, 20.0), 50.0);
788 // Nunca negativo.
789 assert_eq!(ensure_visible(50.0, vp, -10.0, 20.0), 0.0);
790 }
791
792 #[test]
793 fn approach_aterriza_exacto() {
794 // Se acerca pero no salta.
795 let a = approach(0.0, 100.0, 0.25);
796 assert!(a > 0.0 && a < 100.0);
797 // Diferencia < 0.5 px → aterriza exacto.
798 assert_eq!(approach(99.8, 100.0, 0.25), 100.0);
799 // factor 1.0 salta de una.
800 assert_eq!(approach(0.0, 100.0, 1.0), 100.0);
801 }
802
803 #[test]
804 fn fling_decae_y_se_detiene() {
805 // Con fricción <1, la velocidad decae cada paso y el delta tiene el
806 // signo de la velocidad.
807 let (v1, d1) = fling_step(1000.0, 0.016, FLING_FRICTION);
808 assert!(v1 < 1000.0 && v1 > 0.0);
809 assert!(d1 > 0.0 && d1 < 1000.0 * 0.016 + 0.01); // < rectángulo v·dt
810 // Tras muchos pasos de 16 ms, termina por debajo del umbral.
811 let mut v = 1200.0_f32;
812 let mut steps = 0;
813 while !fling_settled(v) && steps < 100_000 {
814 v = fling_step(v, 0.016, FLING_FRICTION).0;
815 steps += 1;
816 }
817 assert!(fling_settled(v));
818 // Velocidad negativa → delta negativo (scrollea al revés).
819 let (_, dneg) = fling_step(-500.0, 0.016, FLING_FRICTION);
820 assert!(dneg < 0.0);
821 // friction = 1.0 (sin fricción) → delta = v·dt exacto.
822 let (v2, d2) = fling_step(300.0, 0.02, 1.0);
823 assert!((v2 - 300.0).abs() < 1e-3);
824 assert!((d2 - 6.0).abs() < 1e-3);
825 }
826
827 #[test]
828 fn rubber_band_amortigua() {
829 let dim = 600.0;
830 // Siempre menor en magnitud que el overscroll crudo.
831 assert!(rubber_band(100.0, dim) < 100.0);
832 assert!(rubber_band(100.0, dim) > 0.0);
833 // Conserva el signo.
834 assert!(rubber_band(-80.0, dim) < 0.0);
835 // Rendimiento decreciente: estirar 2× no duplica el desplazamiento.
836 let a = rubber_band(100.0, dim);
837 let b = rubber_band(200.0, dim);
838 assert!(b > a && b < 2.0 * a);
839 // Cerca de 0 es casi lineal (poca amortiguación todavía).
840 assert!(rubber_band(0.0, dim).abs() < 1e-6);
841 }
842
843 #[test]
844 fn sliver_colapso_y_max() {
845 // Header 200→64, viewport 500, contenido 1200.
846 let (max_h, min_h) = (200.0, 64.0);
847 // Offset 0 → expandido, frac 0.
848 assert_eq!(collapsed_height(0.0, max_h, min_h), 200.0);
849 assert_eq!(collapse_fraction(0.0, max_h, min_h), 0.0);
850 // A mitad del rango (68px de 136) → ~0.5 y altura ~132.
851 let mid = (max_h - min_h) / 2.0; // 68
852 assert!((collapse_fraction(mid, max_h, min_h) - 0.5).abs() < 1e-3);
853 assert!((collapsed_height(mid, max_h, min_h) - 132.0).abs() < 1e-3);
854 // Pasado el rango → fijado al mínimo, frac 1.
855 assert_eq!(collapsed_height(500.0, max_h, min_h), 64.0);
856 assert_eq!(collapse_fraction(500.0, max_h, min_h), 1.0);
857 // Max offset = rango (136) + scroll del cuerpo bajo el header mínimo.
858 let body_vp = 500.0 - min_h; // 436
859 let expected = 136.0 + max_offset(1200.0, body_vp);
860 assert!((sliver_max_offset(1200.0, 500.0, max_h, min_h) - expected).abs() < 1e-3);
861 }
862
863 #[test]
864 fn sticky_pegado_y_empujado() {
865 // Sección [100, 100+300], encabezado 40px de alto.
866 let (top, sh, hh) = (100.0, 300.0, 40.0);
867 // Antes de llegar al tope (offset 50 < 100): posición natural 50.
868 assert_eq!(sticky_y(50.0, top, sh, hh), 50.0);
869 // Dentro de la sección (offset 200 > top): pegado al tope (0).
870 assert_eq!(sticky_y(200.0, top, sh, hh), 0.0);
871 // Cerca del fondo de la sección: la próxima lo empuja hacia arriba (<0).
872 // section_bottom - hh = (100+300-380) - 40 = -20.
873 assert_eq!(sticky_y(380.0, top, sh, hh), -20.0);
874 }
875
876 #[test]
877 fn thumb_proporcional_y_topes() {
878 // Contenido entra entero → thumb cubre todo, sin travel.
879 let (h, y, opp) = thumb_geometry(0.0, 200.0, 300.0);
880 assert_eq!((h, y, opp), (300.0, 0.0, 0.0));
881 // Contenido 3× viewport → thumb ≈ 1/3 (clampeado a MIN_THUMB).
882 let (h, y, _) = thumb_geometry(0.0, 900.0, 300.0);
883 assert!((h - 100.0).abs() < 0.01);
884 assert_eq!(y, 0.0);
885 // En el máximo offset, el thumb toca el fondo del track.
886 let max = max_offset(900.0, 300.0);
887 let (h2, y2, _) = thumb_geometry(max, 900.0, 300.0);
888 assert!((y2 + h2 - 300.0).abs() < 0.01);
889 }
890}