tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ main widgets/scroll/src/lib.rs Rust · 1133 siq'ikuna · 45.0 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

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
51
52// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
53// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
54// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
55#![no_std]
56
57extern crate alloc;
58
59use alloc::vec;
60use alloc::sync::Arc;
61
62use llimphi_compositor::llimphi_layout::taffy::{
63    prelude::{auto, length, percent, Position, Rect, Size, Style},
64};
65use llimphi_compositor::peniko::Color;
66use llimphi_compositor::{DragPhase, View};
67
68/// Alto mínimo del thumb en px — para que no desaparezca con contenido
69/// muy largo.
70const MIN_THUMB: f32 = 28.0;
71/// Px de desplazamiento por "línea" de rueda. Aproxima el step de scroll
72/// de un editor (≈3 líneas de texto).
73pub const DEFAULT_LINE_PX: f32 = 48.0;
74/// Ancho de la barra de scroll en px.
75pub const DEFAULT_BAR_WIDTH: f32 = 10.0;
76
77/// Factor de alpha por defecto para el thumb en reposo — tenue moderno
78/// estilo Chromium/Edge/Safari: visible pero discreto. Al hover sobre la
79/// barra recupera alpha completo. `1.0` reproduce el comportamiento
80/// histórico (thumb siempre opaco).
81pub const DEFAULT_THUMB_IDLE_ALPHA: f32 = 0.55;
82
83/// Colores de la barra de scroll.
84#[derive(Debug, Clone, Copy)]
85pub struct ScrollPalette {
86    /// Canal de fondo (track).
87    pub track: Color,
88    /// Pulgar (thumb) en reposo.
89    pub thumb: Color,
90    /// Pulgar al pasar el cursor.
91    pub thumb_hover: Color,
92    /// Ancho de la barra y px por línea de rueda.
93    pub bar_width: f32,
94    pub line_px: f32,
95    /// Multiplicador de alpha aplicado al `thumb` en reposo. `1.0` deja
96    /// el color sin tocar (comportamiento legacy); `≤ 0.0` esconde el
97    /// thumb del todo en reposo. El `hover_fill` no se ve afectado: al
98    /// pasar el cursor sobre la barra el thumb recupera el alpha completo
99    /// del `thumb_hover`.
100    pub thumb_idle_alpha: f32,
101}
102
103impl Default for ScrollPalette {
104    fn default() -> Self {
105        Self::from_theme(&llimphi_theme::Theme::dark())
106    }
107}
108
109impl ScrollPalette {
110    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
111        Self {
112            track: t.bg_panel_alt,
113            thumb: t.border,
114            thumb_hover: t.accent,
115            bar_width: DEFAULT_BAR_WIDTH,
116            line_px: DEFAULT_LINE_PX,
117            thumb_idle_alpha: DEFAULT_THUMB_IDLE_ALPHA,
118        }
119    }
120
121    /// Devuelve la `ScrollPalette` con el comportamiento histórico (thumb
122    /// opaco en reposo, sin auto-hide visual). Para apps que dependen del
123    /// look anterior al cambio del 2026-06-07.
124    pub fn opaque(mut self) -> Self {
125        self.thumb_idle_alpha = 1.0;
126        self
127    }
128}
129
130/// Máximo offset posible: cuánto se puede desplazar antes de que el final
131/// del contenido toque el borde inferior del viewport. `0` si el contenido
132/// entra entero.
133pub fn max_offset(content_len: f32, viewport_len: f32) -> f32 {
134    (content_len - viewport_len).max(0.0)
135}
136
137/// Acota `offset` a `[0, max_offset]`. El caller lo usa en su `update`
138/// tras sumar el delta de [`scroll_y`].
139pub fn clamp_offset(offset: f32, content_len: f32, viewport_len: f32) -> f32 {
140    offset.clamp(0.0, max_offset(content_len, viewport_len))
141}
142
143/// Publica el alto **medido** de un área de scroll: el widget lo escribe
144/// cuando se pinta y el host lo lee en el cuadro siguiente para pasarlo como
145/// `viewport_len`.
146///
147/// Existe porque el alto disponible **no se sabe** hasta que taffy repartió, o
148/// sea después de armar el `View`. Un host que ya conoce el alto de su panel
149/// puede pasarlo directo y no usar esto; el resto paga un cuadro de latencia en
150/// el primero y en cada resize, que es lo que hace cualquier lista con scroll.
151#[derive(Debug, Clone, Default)]
152pub struct AltoVisible(Arc<core::sync::atomic::AtomicU32>);
153
154impl AltoVisible {
155    pub fn new() -> Self {
156        Self::default()
157    }
158
159    /// Alto (px) del último cuadro pintado; `0.0` si todavía no se pintó.
160    pub fn px(&self) -> f32 {
161        f32::from_bits(self.0.load(core::sync::atomic::Ordering::Relaxed))
162    }
163
164    /// Lo escribe el widget desde su `paint_with`. Público porque el área la
165    /// compone cada widget con su propio layout.
166    pub fn escribir(&self, v: f32) {
167        self.0.store(v.max(0.0).to_bits(), core::sync::atomic::Ordering::Relaxed);
168    }
169}
170
171/// Lo mismo que [`AltoVisible`] pero de **los dos ejes**, para las áreas que
172/// scrollean en 2D ([`scroll_xy`]).
173///
174/// Un área horizontal necesita saber su ancho por el mismo motivo por el que la
175/// vertical necesita su alto: sin viewport no hay tope que clampear ni pulgar
176/// que dimensionar, y el ancho tampoco se sabe hasta que taffy repartió. Medir
177/// los dos con dos `AltoVisible` sueltos funcionaría, pero uno de ellos se
178/// llamaría "alto" y guardaría un ancho.
179#[derive(Debug, Clone, Default)]
180pub struct TamanoVisible {
181    ancho: AltoVisible,
182    alto: AltoVisible,
183}
184
185impl TamanoVisible {
186    pub fn new() -> Self {
187        Self::default()
188    }
189
190    /// `(ancho, alto)` del último cuadro pintado; `(0.0, 0.0)` si todavía no se
191    /// pintó.
192    pub fn px(&self) -> (f32, f32) {
193        (self.ancho.px(), self.alto.px())
194    }
195
196    /// Lo escribe el widget desde su `paint_with`, con el rect que le tocó.
197    pub fn escribir(&self, ancho: f32, alto: f32) {
198        self.ancho.escribir(ancho);
199        self.alto.escribir(alto);
200    }
201}
202
203/// El desplazamiento con el que **pintar** el contenido. Sin alto medido
204/// todavía (`viewport_len <= 0`, el primer cuadro) se pinta desde arriba.
205///
206/// No es una precaución de más: [`clamp_offset`] con viewport `0` da por bueno
207/// **cualquier** offset —el sobrante le parece el contenido entero—, así que
208/// una lista que se re-monta con un offset guardado arrancaría desplazada a un
209/// lugar que todavía no puede validar, y saltaría en el cuadro siguiente.
210/// Mejor un cuadro desde arriba que un salto.
211pub fn offset_efectivo(offset: f32, viewport_len: f32) -> f32 {
212    if viewport_len <= 0.0 {
213        0.0
214    } else {
215        offset.max(0.0)
216    }
217}
218
219/// Scroll vertical de un cuerpo, tal como lo piden los widgets de lista
220/// (`list`, `tree`, `detail-table`): el estado vive en el host y esto es lo que
221/// hay que pasarle al widget para que el cuerpo se desplace.
222pub struct DesplazamientoV<Msg> {
223    /// Offset actual (px). El host lo clampea con [`clamp_offset`].
224    pub offset: f32,
225    /// Alto visible. `0.0` = todavía sin medir (ver [`offset_efectivo`]).
226    pub viewport_len: f32,
227    /// Rueda / arrastre de la barra: delta en px a sumar al `offset`.
228    pub on_scroll: Arc<dyn Fn(f32) -> Msg + Send + Sync>,
229}
230
231/// Envuelve `contenido` (de alto `contenido_len`) en un área scrolleable que
232/// **toma el alto sobrante** de su padre y, si se le da un [`AltoVisible`],
233/// publica cuánto resultó ser.
234///
235/// Es la pieza que comparten los widgets de lista para no reimplementar cada
236/// uno su scroll: rueda, barra arrastrable, clamp y fling ya viven en
237/// [`scroll_y`], y lo único que agregaban por su cuenta era el pegamento de
238/// layout y la medición.
239pub fn cuerpo_scrolleable<Msg: Clone + 'static>(
240    contenido: View<Msg>,
241    contenido_len: f32,
242    d: DesplazamientoV<Msg>,
243    medidor: Option<AltoVisible>,
244    palette: &ScrollPalette,
245) -> View<Msg> {
246    let DesplazamientoV { offset, viewport_len, on_scroll } = d;
247    let area = scroll_y(
248        offset_efectivo(offset, viewport_len),
249        contenido_len,
250        viewport_len,
251        contenido,
252        move |dy: f32| (on_scroll)(dy),
253        palette,
254    );
255    let caja = View::new(Style {
256        flex_direction: llimphi_compositor::llimphi_layout::taffy::prelude::FlexDirection::Column,
257        flex_grow: 1.0,
258        flex_basis: length(0.0_f32),
259        size: Size {
260            width: percent(1.0_f32),
261            height: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
262        },
263        // Sin este min-height un hijo alto empuja al flex y el área deja de
264        // recortar: el cuerpo crece en vez de scrollear.
265        min_size: Size {
266            width: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
267            height: length(0.0_f32),
268        },
269        ..Default::default()
270    })
271    .children(vec![area]);
272    match medidor {
273        Some(m) => caja.paint_with(move |_s, _ts, rect| m.escribir(rect.h)),
274        None => caja,
275    }
276}
277
278/// Devuelve el offset que deja **visible** el intervalo vertical
279/// `[item_top, item_top + item_h]` dentro de un viewport de alto
280/// `viewport_len`, partiendo de `offset`. Si ya está visible, lo devuelve
281/// sin cambios. Pensado para "llevar la selección a la vista" al navegar
282/// con teclado (flechas, Page Up/Down). El resultado se acota a `≥ 0`; el
283/// caller puede clampear arriba con [`clamp_offset`] si lo necesita.
284pub fn ensure_visible(offset: f32, viewport_len: f32, item_top: f32, item_h: f32) -> f32 {
285    if item_top < offset {
286        // El item arranca por encima del viewport: subí hasta su tope.
287        item_top.max(0.0)
288    } else if item_top + item_h > offset + viewport_len {
289        // El item termina por debajo: baja hasta que su fondo toque el borde.
290        (item_top + item_h - viewport_len).max(0.0)
291    } else {
292        offset
293    }
294}
295
296/// Un paso de aproximación exponencial de `current` hacia `target`
297/// (scroll suave / inercia). `factor ∈ (0, 1]`: 1.0 salta de una, 0.2
298/// desliza suave. Cuando la diferencia cae por debajo de 0.5 px aterriza
299/// exacto en `target` (evita el "casi-llega" infinito). El caller lo
300/// dispara por frame vía `Handle::spawn_periodic` guardando `target` en
301/// su `Model`.
302pub fn approach(current: f32, target: f32, factor: f32) -> f32 {
303    let f = factor.clamp(0.0, 1.0);
304    let next = current + (target - current) * f;
305    if (target - next).abs() < 0.5 {
306        target
307    } else {
308        next
309    }
310}
311
312/// Velocidad (px/s) por debajo de la cual una inercia se considera detenida.
313pub const FLING_STOP: f32 = 8.0;
314/// Fricción por defecto del fling: fracción de velocidad que **sobrevive por
315/// segundo** (más chico = frena antes). 0.0015 ≈ deslizamiento tipo lista
316/// táctil; subilo (p. ej. 0.1) para frenar rápido.
317pub const FLING_FRICTION: f32 = 0.0015;
318
319/// Un paso de **inercia** (fling): dado `velocity` en px/s y `dt` en segundos,
320/// devuelve `(nueva_velocidad, delta_offset)` bajo decaimiento exponencial
321/// `v(t) = v·friction^t`. `friction ∈ (0,1]` es la fracción de velocidad que
322/// sobrevive por segundo. El `delta` es la integral exacta de la velocidad
323/// sobre el paso (no el rectángulo `v·dt`), así el frenado no depende del
324/// frame-rate. El caller suma `delta` al offset (clampeando con
325/// [`clamp_offset`]) y reusa `nueva_velocidad` el próximo frame hasta que
326/// [`fling_settled`] dé `true`. Es el análogo de [`approach`] pero para
327/// "soltar con envión" en vez de "ir hacia un objetivo".
328pub fn fling_step(velocity: f32, dt: f32, friction: f32) -> (f32, f32) {
329    let f = friction.clamp(1e-6, 1.0);
330    let decay = powf(f, dt.max(0.0));
331    let new_v = velocity * decay;
332    let delta = if (f - 1.0).abs() < 1e-6 {
333        velocity * dt
334    } else {
335        // ∫₀^dt v·f^s ds = v·(f^dt − 1)/ln f.
336        velocity * (decay - 1.0) / ln(f)
337    };
338    (new_v, delta)
339}
340
341// `powf` y `ln` son métodos de la stdlib, no operaciones del lenguaje: sin
342// `std` (la jaula, el kernel) no existen y van por libm.
343#[inline]
344fn powf(x: f32, y: f32) -> f32 {
345    #[cfg(feature = "std")]
346    {
347        x.powf(y)
348    }
349    #[cfg(not(feature = "std"))]
350    {
351        libm::powf(x, y)
352    }
353}
354
355#[inline]
356fn ln(x: f32) -> f32 {
357    #[cfg(feature = "std")]
358    {
359        x.ln()
360    }
361    #[cfg(not(feature = "std"))]
362    {
363        libm::logf(x)
364    }
365}
366
367/// ¿La inercia ya se detuvo? `true` cuando `|velocity| < FLING_STOP` — el
368/// caller corta el ticker y deja el offset quieto.
369pub fn fling_settled(velocity: f32) -> bool {
370    velocity.abs() < FLING_STOP
371}
372
373/// Resistencia elástica (rubber-band) al **sobrepasar un borde**, estilo iOS:
374/// dado cuánto se pasó del límite (`overscroll`, px; el signo se conserva) y la
375/// dimensión del viewport (`dim`), devuelve el desplazamiento visual
376/// **amortiguado** — siempre menor en magnitud que `overscroll`, con
377/// rendimiento decreciente cuanto más se estira. El caller lo usa para pintar
378/// el contenido un poco más allá del tope mientras arrastra, y lo libera
379/// (anima a 0 con [`approach`]) al soltar. Constante 0.55 = la de Apple.
380pub fn rubber_band(overscroll: f32, dim: f32) -> f32 {
381    if dim <= 0.0 || overscroll == 0.0 {
382        return overscroll;
383    }
384    const C: f32 = 0.55;
385    let x = overscroll.abs();
386    (1.0 - 1.0 / (x * C / dim + 1.0)) * dim * overscroll.signum()
387}
388
389// ── Auto-hide del thumb (timer-driven, lo maneja la app) ──
390
391/// Segundos que el thumb queda a opacidad plena tras la última interacción de
392/// scroll antes de empezar a desvanecerse.
393pub const THUMB_HOLD_SECS: f32 = 1.2;
394/// Segundos que tarda el thumb en desvanecerse de pleno a invisible.
395pub const THUMB_FADE_SECS: f32 = 0.4;
396
397/// Opacidad del thumb en función de los segundos desde la última interacción
398/// de scroll (auto-hide estilo overlay móvil/Chromium): pleno (`1.0`) durante
399/// [`THUMB_HOLD_SECS`], luego baja linealmente a `0.0` en [`THUMB_FADE_SECS`].
400///
401/// El bucle Elm reconstruye el `View` sin estado retenido, así que el timer lo
402/// lleva la app (igual que el fling): trackea `last_scroll: Instant` en el
403/// Model, reseteándolo en cada `on_scroll`, y antes de llamar a
404/// `scroll_y`/`scroll_xy` haz `palette.thumb_idle_alpha =
405/// thumb_autohide_alpha(last_scroll.elapsed().as_secs_f32())`. Mientras
406/// [`thumb_autohide_active`] sea `true`, pedí frames (`Handle::spawn_periodic`).
407/// En reposo el thumb queda invisible pero su `hover_fill` lo revela al pasar
408/// el cursor por el borde.
409pub fn thumb_autohide_alpha(secs_since_scroll: f32) -> f32 {
410    if secs_since_scroll <= THUMB_HOLD_SECS {
411        return 1.0;
412    }
413    let fade = (secs_since_scroll - THUMB_HOLD_SECS) / THUMB_FADE_SECS.max(1e-3);
414    (1.0 - fade).clamp(0.0, 1.0)
415}
416
417/// `true` mientras el thumb sigue desvaneciéndose (la app debe seguir pidiendo
418/// frames). Una vez invisible (pasado hold+fade), devuelve `false` y la app
419/// puede dejar de tickear.
420pub fn thumb_autohide_active(secs_since_scroll: f32) -> bool {
421    secs_since_scroll < THUMB_HOLD_SECS + THUMB_FADE_SECS
422}
423
424// ── Pull-to-refresh (compone sobre el overscroll que ya maneja la app) ──
425
426/// Distancia de overscroll (px) a la que el pull-to-refresh queda **armado**
427/// por defecto: soltar más allá dispara el refresh.
428pub const DEFAULT_PULL_THRESHOLD: f32 = 64.0;
429
430/// Progreso del gesto pull-to-refresh en `[0,1]`: qué fracción del umbral
431/// cubre el `overscroll` actual en el tope (distancia que el contenido fue
432/// arrastrado más allá del borde superior — la app ya la computa para el
433/// [`rubber_band`]). Alimenta el barrido de [`pull_indicator_view`].
434pub fn pull_progress(overscroll_px: f32, threshold_px: f32) -> f32 {
435    if threshold_px <= 0.0 {
436        return 0.0;
437    }
438    (overscroll_px / threshold_px).clamp(0.0, 1.0)
439}
440
441/// `true` si el overscroll alcanzó el umbral — soltar en este punto dispara el
442/// refresh. La app lo chequea en `DragPhase::End`/al asentar el rubber-band.
443pub fn pull_triggered(overscroll_px: f32, threshold_px: f32) -> bool {
444    threshold_px > 0.0 && overscroll_px >= threshold_px
445}
446
447// ── Slivers: app-bar colapsable + sticky headers (seam "extent-por-offset") ──
448
449/// Altura de un **app-bar colapsable** dado el `offset` de scroll: arranca en
450/// `header_max` (offset 0) y baja linealmente hasta `header_min`, donde queda
451/// fijado (pinned). El "rango de colapso" es `header_max - header_min`.
452pub fn collapsed_height(offset: f32, header_max: f32, header_min: f32) -> f32 {
453    (header_max - offset.max(0.0)).clamp(header_min, header_max)
454}
455
456/// Fracción de colapso del app-bar en `[0, 1]`: `0` = expandido (offset 0),
457/// `1` = colapsado al mínimo. El caller la usa para fundir el título, achicar
458/// un subtítulo, bajar la opacidad de una imagen de fondo, etc.
459pub fn collapse_fraction(offset: f32, header_max: f32, header_min: f32) -> f32 {
460    let range = (header_max - header_min).max(0.0);
461    if range <= 0.0 {
462        return 1.0;
463    }
464    (offset.max(0.0) / range).clamp(0.0, 1.0)
465}
466
467/// Offset máximo de scroll con un app-bar colapsable: los `header_max -
468/// header_min` px que consume el colapso **más** lo que scrollee el cuerpo
469/// bajo el header ya fijado en `header_min`. El caller lo usa para clampear.
470pub fn sliver_max_offset(
471    content_len: f32,
472    viewport_len: f32,
473    header_max: f32,
474    header_min: f32,
475) -> f32 {
476    let range = (header_max - header_min).max(0.0);
477    let body_vp = (viewport_len - header_min).max(0.0);
478    range + max_offset(content_len, body_vp)
479}
480
481/// Posición `y` (relativa al tope del viewport) de un encabezado **sticky** de
482/// una sección que ocupa `[section_top, section_top + section_h]` en
483/// coordenadas de contenido, con altura de encabezado `header_h`. Mientras la
484/// sección está en pantalla, el encabezado se **pega al tope** (`y = 0`); al
485/// llegar la próxima sección, ésta lo **empuja** hacia arriba (no pasa de
486/// `section_bottom - header_h`). Antes de que la sección llegue al tope, sigue
487/// su posición natural. El caller posiciona el encabezado absoluto en esta `y`.
488/// Cuánto de un `delta` (px, `+` = hacia abajo) puede **consumir** un área con
489/// `offset` actual y `max_off`: el tramo que le queda hasta el tope en esa
490/// dirección. Devuelve `(consumido, sobrante)`, ambos con el signo del delta.
491/// Base del reparto parcial del scroll anidado (`View::on_scroll_take`): el
492/// sobrante sigue al ancestro scrollable en el mismo evento.
493pub fn scroll_take(delta: f32, offset: f32, max_off: f32) -> (f32, f32) {
494    let can = if delta > 0.0 {
495        delta.min((max_off - offset).max(0.0))
496    } else if delta < 0.0 {
497        delta.max(-offset.max(0.0))
498    } else {
499        0.0
500    };
501    (can, delta - can)
502}
503
504pub fn sticky_y(offset: f32, section_top: f32, section_h: f32, header_h: f32) -> f32 {
505    let natural = section_top - offset; // y del encabezado sin sticky
506    let section_bottom = section_top + section_h - offset;
507    natural.max(0.0).min(section_bottom - header_h)
508}
509
510/// Geometría del thumb: `(altura, posición_y)` dentro del track de alto
511/// `viewport_len`, y `offset_por_px` (cuánto offset de contenido equivale
512/// a 1 px de arrastre del thumb). Público para tests y para callers que
513/// quieran pintar su propia barra.
514pub fn thumb_geometry(offset: f32, content_len: f32, viewport_len: f32) -> (f32, f32, f32) {
515    let max_off = max_offset(content_len, viewport_len);
516    if max_off <= 0.0 || content_len <= 0.0 {
517        return (viewport_len, 0.0, 0.0);
518    }
519    let ratio = (viewport_len / content_len).clamp(0.0, 1.0);
520    let thumb_h = (viewport_len * ratio).clamp(MIN_THUMB.min(viewport_len), viewport_len);
521    let travel = (viewport_len - thumb_h).max(0.0);
522    let thumb_y = if max_off > 0.0 {
523        (offset / max_off).clamp(0.0, 1.0) * travel
524    } else {
525        0.0
526    };
527    let offset_per_px = if travel > 0.0 { max_off / travel } else { 0.0 };
528    (thumb_h, thumb_y, offset_per_px)
529}
530
531/// Delta de offset para un **click en el canal** de la barra (page-jump, como
532/// un scrollbar de escritorio): si el click cae **antes** del thumb, sube una
533/// página (`-viewport·0.9`); si cae **después**, baja una. `0` si cae sobre el
534/// thumb (ese click lo maneja el drag). Pura y testeable; el caller la suma al
535/// offset con [`clamp_offset`].
536pub fn track_page_jump(click_y: f32, thumb_y: f32, thumb_h: f32, viewport_len: f32) -> f32 {
537    let page = viewport_len * 0.9;
538    if click_y < thumb_y {
539        -page
540    } else if click_y > thumb_y + thumb_h {
541        page
542    } else {
543        0.0
544    }
545}
546
547/// Área de scroll vertical. `offset` es el desplazamiento actual (px, ya
548/// clampeado por el caller). `content_len`/`viewport_len` el alto total y
549/// visible. `content` se desplaza `-offset` y se recorta al viewport.
550/// `on_scroll(delta_px)` se invoca con el delta a sumar al offset (rueda
551/// y arrastre de barra); el caller acumula con [`clamp_offset`].
552pub fn scroll_y<Msg, F>(
553    offset: f32,
554    content_len: f32,
555    viewport_len: f32,
556    content: View<Msg>,
557    on_scroll: F,
558    palette: &ScrollPalette,
559) -> View<Msg>
560where
561    // `Msg` no necesita `Send + Sync`: los closures de rueda/arrastre
562    // capturan el `Arc<dyn Fn + Send + Sync>`, no un `Msg`. Sólo se exige
563    // `Clone` (para montar el `View`) y `'static`.
564    Msg: Clone + 'static,
565    F: Fn(f32) -> Msg + Send + Sync + 'static,
566{
567    let on_scroll = Arc::new(on_scroll);
568
569    // Contenido desplazado: nodo absoluto anclado a left/right (toma el
570    // ancho del viewport) con top = -offset y alto natural. El overflow se
571    // recorta por el `clip` del viewport.
572    let content_wrap = View::new(Style {
573        position: Position::Absolute,
574        inset: Rect {
575            top: length(-offset),
576            left: length(0.0_f32),
577            right: length(0.0_f32),
578            bottom: auto(),
579        },
580        ..Default::default()
581    })
582    .children(vec![content]);
583
584    let mut children = vec![content_wrap];
585
586    // Barra: sólo si hay overflow.
587    if max_offset(content_len, viewport_len) > 0.0 {
588        let (thumb_h, thumb_y, offset_per_px) =
589            thumb_geometry(offset, content_len, viewport_len);
590
591        let on_thumb = on_scroll.clone();
592        let thumb = View::new(Style {
593            position: Position::Absolute,
594            inset: Rect {
595                top: length(thumb_y),
596                right: length(0.0_f32),
597                left: auto(),
598                bottom: auto(),
599            },
600            size: Size {
601                width: length(palette.bar_width),
602                height: length(thumb_h),
603            },
604            ..Default::default()
605        })
606        .fill(palette.thumb.multiply_alpha(palette.thumb_idle_alpha.clamp(0.0, 1.0)))
607        .hover_fill(palette.thumb_hover)
608        .radius((palette.bar_width * 0.5) as f64)
609        .draggable(move |phase, _dx, dy| match phase {
610            // Cada Move trae el delta de px de pantalla del thumb; lo
611            // convertimos a delta de offset de contenido.
612            DragPhase::Move => Some((on_thumb)(dy * offset_per_px)),
613            DragPhase::End => None,
614        });
615
616        // Click en el canal (fuera del thumb) = salto de página hacia el click.
617        let on_track = on_scroll.clone();
618        let track = View::new(Style {
619            position: Position::Absolute,
620            inset: Rect {
621                top: length(0.0_f32),
622                right: length(0.0_f32),
623                bottom: length(0.0_f32),
624                left: auto(),
625            },
626            size: Size {
627                width: length(palette.bar_width),
628                height: auto(),
629            },
630            ..Default::default()
631        })
632        .fill(palette.track)
633        // El canal de la barra: se clickea para saltar de página, así que es un
634        // nodo activable y necesita decir qué es. Era el mudo que aparecía en
635        // todo panel con scroll (los menús XP y GNOME de pata, entre otros).
636        .role(llimphi_compositor::Role::Slider)
637        .aria_label("Barra de desplazamiento")
638        .on_click_at(move |_lx, ly, _w, _h| {
639            let delta = track_page_jump(ly, thumb_y, thumb_h, viewport_len);
640            if delta != 0.0 {
641                Some((on_track)(delta))
642            } else {
643                None
644            }
645        })
646        .children(vec![thumb]);
647
648        children.push(track);
649    }
650
651    // Viewport: alto fijo, ancho del padre, contenido recortado, rueda
652    // local. Position::Relative para ser el bloque contenedor de los
653    // hijos absolutos.
654    //
655    // **Scroll anidado**: si el delta del eje vertical es hacia un extremo
656    // donde ya estamos topados (offset = 0 con dy<0, u offset = max con
657    // dy>0), devolvemos `None` para que el runtime propague el evento al
658    // ancestro scrollable más cercano (lista dentro de panel, etc.).
659    let line_px = palette.line_px;
660    let on_wheel = on_scroll;
661    let max_off = max_offset(content_len, viewport_len);
662    View::new(Style {
663        position: Position::Relative,
664        size: Size {
665            width: percent(1.0_f32),
666            height: length(viewport_len),
667        },
668        ..Default::default()
669    })
670    .clip(true)
671    // Reparto parcial: consume hasta el tope y devuelve el sobrante (en
672    // líneas) para que siga al ancestro scrollable en el mismo evento. El
673    // eje x pasa entero (esta área es vertical).
674    .on_scroll_take(move |dx, dy| {
675        let (can, rest) = scroll_take(dy * line_px, offset, max_off);
676        let msg = (can.abs() > f32::EPSILON).then(|| (on_wheel)(can));
677        (msg, dx, rest / line_px)
678    })
679    .children(children)
680}
681
682/// Área de scroll **2D** (horizontal + vertical). Generaliza [`scroll_y`] a dos
683/// ejes: el contenido toma su tamaño natural y se desplaza `(-x, -y)`, recortado
684/// al viewport; aparece una barra por eje que tenga overflow (ninguna, una o
685/// las dos). Para scroll puramente horizontal, pasa `content_size.1 ==
686/// viewport_size.1` (no sale barra vertical).
687///
688/// `on_scroll(dx, dy)` recibe el **delta en px por eje** a sumar a cada offset
689/// (rueda → ambos ejes; arrastre de la barra vertical → sólo `dy`; horizontal →
690/// sólo `dx`). El caller acumula y clampea cada eje con [`clamp_offset`]. Las
691/// dos barras se solapan en una esquinita inferior-derecha (v1; cosmético).
692pub fn scroll_xy<Msg, F>(
693    offset: (f32, f32),
694    content_size: (f32, f32),
695    viewport_size: (f32, f32),
696    content: View<Msg>,
697    on_scroll: F,
698    palette: &ScrollPalette,
699) -> View<Msg>
700where
701    Msg: Clone + 'static,
702    F: Fn(f32, f32) -> Msg + Send + Sync + 'static,
703{
704    let (ox, oy) = offset;
705    let (cw, ch) = content_size;
706    let (vw, vh) = viewport_size;
707    let on_scroll = Arc::new(on_scroll);
708
709    // Contenido a tamaño natural, desplazado (-x, -y). right/bottom = auto para
710    // que no lo achique el viewport (a diferencia de scroll_y, que ancla
711    // left/right para tomar el ancho del viewport).
712    let content_wrap = View::new(Style {
713        position: Position::Absolute,
714        inset: Rect {
715            top: length(-oy),
716            left: length(-ox),
717            right: auto(),
718            bottom: auto(),
719        },
720        ..Default::default()
721    })
722    .children(vec![content]);
723
724    let mut children = vec![content_wrap];
725
726    // Barra vertical (borde derecho) — sólo si hay overflow vertical.
727    if max_offset(ch, vh) > 0.0 {
728        let (thumb_h, thumb_y, opp) = thumb_geometry(oy, ch, vh);
729        let f = on_scroll.clone();
730        let thumb = View::new(Style {
731            position: Position::Absolute,
732            inset: Rect { top: length(thumb_y), right: length(0.0_f32), left: auto(), bottom: auto() },
733            size: Size { width: length(palette.bar_width), height: length(thumb_h) },
734            ..Default::default()
735        })
736        .fill(palette.thumb.multiply_alpha(palette.thumb_idle_alpha.clamp(0.0, 1.0)))
737        .hover_fill(palette.thumb_hover)
738        .radius((palette.bar_width * 0.5) as f64)
739        .draggable(move |phase, _dx, dy| match phase {
740            DragPhase::Move => Some((f)(0.0, dy * opp)),
741            DragPhase::End => None,
742        });
743        let track = View::new(Style {
744            position: Position::Absolute,
745            inset: Rect { top: length(0.0_f32), right: length(0.0_f32), bottom: length(0.0_f32), left: auto() },
746            size: Size { width: length(palette.bar_width), height: auto() },
747            ..Default::default()
748        })
749        .fill(palette.track)
750        .children(vec![thumb]);
751        children.push(track);
752    }
753
754    // Barra horizontal (borde inferior) — sólo si hay overflow horizontal.
755    if max_offset(cw, vw) > 0.0 {
756        let (thumb_w, thumb_x, opp) = thumb_geometry(ox, cw, vw);
757        let f = on_scroll.clone();
758        let thumb = View::new(Style {
759            position: Position::Absolute,
760            inset: Rect { left: length(thumb_x), bottom: length(0.0_f32), top: auto(), right: auto() },
761            size: Size { width: length(thumb_w), height: length(palette.bar_width) },
762            ..Default::default()
763        })
764        .fill(palette.thumb.multiply_alpha(palette.thumb_idle_alpha.clamp(0.0, 1.0)))
765        .hover_fill(palette.thumb_hover)
766        .radius((palette.bar_width * 0.5) as f64)
767        .draggable(move |phase, dx, _dy| match phase {
768            DragPhase::Move => Some((f)(dx * opp, 0.0)),
769            DragPhase::End => None,
770        });
771        let track = View::new(Style {
772            position: Position::Absolute,
773            inset: Rect { left: length(0.0_f32), right: length(0.0_f32), bottom: length(0.0_f32), top: auto() },
774            size: Size { width: auto(), height: length(palette.bar_width) },
775            ..Default::default()
776        })
777        .fill(palette.track)
778        .children(vec![thumb]);
779        children.push(track);
780    }
781
782    // Scroll anidado 2D: si el delta NETO está bloqueado en ambos ejes
783    // (cada componente cae en un extremo del eje correspondiente),
784    // devolvemos `None` para propagar al ancestro scrollable. Si al menos
785    // un eje aún tiene recorrido, el evento se consume entero (como antes).
786    let line_px = palette.line_px;
787    let on_wheel = on_scroll;
788    let max_ox = max_offset(cw, vw);
789    let max_oy = max_offset(ch, vh);
790    View::new(Style {
791        position: Position::Relative,
792        size: Size { width: length(vw), height: length(vh) },
793        ..Default::default()
794    })
795    .clip(true)
796    // Rueda: dy = eje vertical; dx = eje horizontal (ratones/touchpads 2D, o
797    // Shift+rueda en algunos backends). Ambos en px-línea.
798    .on_scroll_take(move |dx, dy| {
799        // Reparto parcial por eje: cada uno consume hasta su tope y el
800        // sobrante (en líneas) sigue al ancestro en el mismo evento.
801        let (cx, rx) = scroll_take(dx * line_px, ox, max_ox);
802        let (cy, ry) = scroll_take(dy * line_px, oy, max_oy);
803        let msg = (cx.abs() > f32::EPSILON || cy.abs() > f32::EPSILON)
804            .then(|| (on_wheel)(cx, cy));
805        (msg, rx / line_px, ry / line_px)
806    })
807    .children(children)
808}
809
810/// Indicador circular del **pull-to-refresh**. Mientras el usuario arrastra
811/// más allá del tope, un arco se completa con `progress` (0→1, de
812/// [`pull_progress`]); al alcanzar 1 el círculo queda cerrado ("armado"). Con
813/// `refreshing = true` gira como spinner (arco de 270° rotando por reloj
814/// absoluto — pedí frames mientras dure). `size_px` es el diámetro.
815///
816/// La app lo posiciona en la zona de overscroll del tope (típico: centrado
817/// arriba, bajando junto con el contenido arrastrado). Es puro paint; no
818/// retiene estado.
819// GATEADO A `std`: esta vista arranca su reloj con `Instant::now()`, que en el
820// perfil sin sistema NO EXISTE a propósito — allá el instante del fotograma
821// entra por argumento, y un uso equivocado debe ser un error de compilación, no
822// una animación congelada.
823#[cfg(feature = "std")]
824pub fn pull_indicator_view<Msg: Clone + 'static>(
825    progress: f32,
826    refreshing: bool,
827    size_px: f32,
828    palette: &ScrollPalette,
829) -> View<Msg> {
830    let p = progress.clamp(0.0, 1.0);
831    let arc_color = palette.thumb;
832    let track_color = palette.track;
833    let started = llimphi_compositor::Instant::now();
834    View::new(Style {
835        size: Size {
836            width: length(size_px),
837            height: length(size_px),
838        },
839        flex_shrink: 0.0,
840        ..Default::default()
841    })
842    .paint_with(move |scene, _ts, rect| {
843        use llimphi_compositor::peniko::kurbo::{Affine, Arc, Point, Shape, Stroke, Vec2};
844        use core::f64::consts::TAU;
845
846        if rect.w <= 0.0 || rect.h <= 0.0 {
847            return;
848        }
849        let d = (rect.w.min(rect.h)) as f64;
850        let stroke_w = (d * 0.1).clamp(1.5, 4.0);
851        let r = d * 0.5 - stroke_w;
852        if r <= 0.0 {
853            return;
854        }
855        let cx = (rect.x + rect.w * 0.5) as f64;
856        let cy = (rect.y + rect.h * 0.5) as f64;
857        let center = Point::new(cx, cy);
858        let radii = Vec2::new(r, r);
859        let stroke = Stroke::new(stroke_w);
860
861        // Anillo de fondo tenue (track).
862        let ring = Arc::new(center, radii, 0.0, TAU, 0.0).to_path(0.2);
863        scene.stroke(&stroke, Affine::IDENTITY, track_color, None, &ring);
864
865        // Arco activo: arranca arriba (12 en punto = -PI/2). Si refresca, gira
866        // un arco de 270°; si no, barre proporcional al progreso.
867        let top = -core::f64::consts::FRAC_PI_2;
868        let (start, sweep) = if refreshing {
869            let spin = started.elapsed().as_secs_f64() * 3.0; // rad/s
870            (top + spin, TAU * 0.75)
871        } else {
872            (top, TAU * p as f64)
873        };
874        if sweep.abs() > 1e-3 {
875            let active = Arc::new(center, radii, start, sweep, 0.0).to_path(0.2);
876            scene.stroke(&stroke, Affine::IDENTITY, arc_color, None, &active);
877        }
878    })
879}
880
881/// **App-bar colapsable + cuerpo scrolleable** en un solo viewport (el sliver
882/// más pedido). Un único `offset` (en el Model) maneja las dos cosas: primero
883/// **colapsa** el header de `header_max` a `header_min` (consume los primeros
884/// `header_max - header_min` px de scroll), y luego **scrollea** el cuerpo bajo
885/// el header ya fijado en `header_min`.
886///
887/// `header(frac)` construye el contenido del header dado `frac ∈ [0,1]` (ver
888/// [`collapse_fraction`]) — el caller lo usa para fundir el título, mostrar una
889/// versión compacta al colapsar, etc. El header se pinta a la altura
890/// [`collapsed_height`] del momento.
891///
892/// `content_len` es el alto natural del cuerpo; el viewport del cuerpo cambia
893/// con el colapso (crece a medida que el header se achica). La rueda funciona
894/// tanto sobre el header como sobre el cuerpo (ambos emiten `on_scroll`). El
895/// caller clampea el offset con [`sliver_max_offset`].
896pub fn sliver_app_bar<Msg, H, F>(
897    offset: f32,
898    header_max: f32,
899    header_min: f32,
900    header: H,
901    content: View<Msg>,
902    content_len: f32,
903    viewport_len: f32,
904    on_scroll: F,
905    palette: &ScrollPalette,
906) -> View<Msg>
907where
908    Msg: Clone + 'static,
909    H: FnOnce(f32) -> View<Msg>,
910    F: Fn(f32) -> Msg + Send + Sync + 'static,
911{
912    let range = (header_max - header_min).max(0.0);
913    let h = collapsed_height(offset, header_max, header_min);
914    let frac = collapse_fraction(offset, header_max, header_min);
915    // El cuerpo recién empieza a scrollear cuando el colapso terminó.
916    let body_offset = (offset - range).max(0.0);
917    let body_vp = (viewport_len - h).max(0.0);
918
919    let on_scroll = Arc::new(on_scroll);
920    let line_px = palette.line_px;
921
922    // Header pinned (altura `h`), recortado, con rueda propia.
923    let s_head = on_scroll.clone();
924    let header_box = View::new(Style {
925        size: Size { width: percent(1.0_f32), height: length(h) },
926        ..Default::default()
927    })
928    .clip(true)
929    .on_scroll(move |_dx, dy| Some((s_head)(dy * line_px)))
930    .children(vec![header(frac)]);
931
932    // Cuerpo: reusa scroll_y con el viewport restante y el offset del cuerpo.
933    let s_body = on_scroll;
934    let body = scroll_y(
935        body_offset,
936        content_len,
937        body_vp,
938        content,
939        move |d| (s_body)(d),
940        palette,
941    );
942
943    View::new(Style {
944        flex_direction:
945            llimphi_compositor::llimphi_layout::taffy::prelude::FlexDirection::Column,
946        size: Size { width: percent(1.0_f32), height: length(viewport_len) },
947        ..Default::default()
948    })
949    .clip(true)
950    .children(vec![header_box, body])
951}
952
953#[cfg(test)]
954mod tests {
955    use super::*;
956
957    #[test]
958    fn scroll_take_consume_hasta_el_tope_y_devuelve_el_resto() {
959        // offset 70 de max 100: bajando 50 consume 30 y sobran 20.
960        assert_eq!(scroll_take(50.0, 70.0, 100.0), (30.0, 20.0));
961        // Subiendo 50 con offset 20: consume -20 y sobran -30.
962        assert_eq!(scroll_take(-50.0, 20.0, 100.0), (-20.0, -30.0));
963        // Lejos de los topes: consume todo, resto 0.
964        assert_eq!(scroll_take(10.0, 50.0, 100.0), (10.0, 0.0));
965        assert_eq!(scroll_take(-10.0, 50.0, 100.0), (-10.0, 0.0));
966        // Topado exacto: no consume nada.
967        assert_eq!(scroll_take(10.0, 100.0, 100.0), (0.0, 10.0));
968        assert_eq!(scroll_take(-10.0, 0.0, 100.0), (0.0, -10.0));
969        // Overscrolleado (rubber-band, offset fuera de rango): no consume —
970        // se comporta como topado, igual que el gate at_top/at_bottom viejo.
971        assert_eq!(scroll_take(-5.0, -20.0, 100.0), (0.0, -5.0));
972        assert_eq!(scroll_take(5.0, 120.0, 100.0), (0.0, 5.0));
973        // Delta cero.
974        assert_eq!(scroll_take(0.0, 50.0, 100.0), (0.0, 0.0));
975    }
976
977    #[test]
978    fn track_page_jump_sube_baja_o_ignora_sobre_el_thumb() {
979        // thumb en y=100, alto 50 → ocupa [100,150]; viewport 400 → página 360.
980        // Click arriba del thumb → sube una página.
981        assert_eq!(track_page_jump(40.0, 100.0, 50.0, 400.0), -360.0);
982        // Click debajo del thumb → baja una página.
983        assert_eq!(track_page_jump(300.0, 100.0, 50.0, 400.0), 360.0);
984        // Click sobre el thumb → sin salto (lo maneja el drag).
985        assert_eq!(track_page_jump(120.0, 100.0, 50.0, 400.0), 0.0);
986    }
987
988    #[test]
989    fn thumb_autohide_hold_fade_y_oculto() {
990        // Pleno durante el hold.
991        assert_eq!(thumb_autohide_alpha(0.0), 1.0);
992        assert_eq!(thumb_autohide_alpha(THUMB_HOLD_SECS), 1.0);
993        // A mitad del fade, alpha intermedio.
994        let mid = thumb_autohide_alpha(THUMB_HOLD_SECS + THUMB_FADE_SECS * 0.5);
995        assert!(mid > 0.0 && mid < 1.0, "alpha intermedio: {mid}");
996        // Pasado hold+fade, invisible y la app puede dejar de tickear.
997        assert_eq!(thumb_autohide_alpha(THUMB_HOLD_SECS + THUMB_FADE_SECS + 0.1), 0.0);
998        assert!(thumb_autohide_active(THUMB_HOLD_SECS + THUMB_FADE_SECS * 0.5));
999        assert!(!thumb_autohide_active(THUMB_HOLD_SECS + THUMB_FADE_SECS + 0.1));
1000    }
1001
1002    #[test]
1003    fn pull_progress_y_trigger() {
1004        assert_eq!(pull_progress(0.0, 64.0), 0.0);
1005        assert_eq!(pull_progress(32.0, 64.0), 0.5);
1006        assert_eq!(pull_progress(128.0, 64.0), 1.0); // satura
1007        assert_eq!(pull_progress(10.0, 0.0), 0.0); // umbral inválido
1008        assert!(!pull_triggered(63.9, 64.0));
1009        assert!(pull_triggered(64.0, 64.0));
1010        assert!(pull_triggered(100.0, 64.0));
1011    }
1012
1013    #[test]
1014    fn max_y_clamp() {
1015        assert_eq!(max_offset(1000.0, 300.0), 700.0);
1016        assert_eq!(max_offset(200.0, 300.0), 0.0); // entra entero
1017        assert_eq!(clamp_offset(-50.0, 1000.0, 300.0), 0.0);
1018        assert_eq!(clamp_offset(9999.0, 1000.0, 300.0), 700.0);
1019        assert_eq!(clamp_offset(400.0, 1000.0, 300.0), 400.0);
1020    }
1021
1022    #[test]
1023    fn ensure_visible_arriba_abajo_y_sin_cambio() {
1024        let vp = 300.0;
1025        // Item por encima del offset → subir hasta su tope.
1026        assert_eq!(ensure_visible(500.0, vp, 100.0, 20.0), 100.0);
1027        // Item por debajo del fondo visible → bajar lo justo.
1028        assert_eq!(ensure_visible(0.0, vp, 400.0, 20.0), 120.0); // 400+20-300
1029        // Item ya visible → sin cambios.
1030        assert_eq!(ensure_visible(50.0, vp, 100.0, 20.0), 50.0);
1031        // Nunca negativo.
1032        assert_eq!(ensure_visible(50.0, vp, -10.0, 20.0), 0.0);
1033    }
1034
1035    #[test]
1036    fn approach_aterriza_exacto() {
1037        // Se acerca pero no salta.
1038        let a = approach(0.0, 100.0, 0.25);
1039        assert!(a > 0.0 && a < 100.0);
1040        // Diferencia < 0.5 px → aterriza exacto.
1041        assert_eq!(approach(99.8, 100.0, 0.25), 100.0);
1042        // factor 1.0 salta de una.
1043        assert_eq!(approach(0.0, 100.0, 1.0), 100.0);
1044    }
1045
1046    #[test]
1047    fn fling_decae_y_se_detiene() {
1048        // Con fricción <1, la velocidad decae cada paso y el delta tiene el
1049        // signo de la velocidad.
1050        let (v1, d1) = fling_step(1000.0, 0.016, FLING_FRICTION);
1051        assert!(v1 < 1000.0 && v1 > 0.0);
1052        assert!(d1 > 0.0 && d1 < 1000.0 * 0.016 + 0.01); // < rectángulo v·dt
1053        // Tras muchos pasos de 16 ms, termina por debajo del umbral.
1054        let mut v = 1200.0_f32;
1055        let mut steps = 0;
1056        while !fling_settled(v) && steps < 100_000 {
1057            v = fling_step(v, 0.016, FLING_FRICTION).0;
1058            steps += 1;
1059        }
1060        assert!(fling_settled(v));
1061        // Velocidad negativa → delta negativo (scrollea al revés).
1062        let (_, dneg) = fling_step(-500.0, 0.016, FLING_FRICTION);
1063        assert!(dneg < 0.0);
1064        // friction = 1.0 (sin fricción) → delta = v·dt exacto.
1065        let (v2, d2) = fling_step(300.0, 0.02, 1.0);
1066        assert!((v2 - 300.0).abs() < 1e-3);
1067        assert!((d2 - 6.0).abs() < 1e-3);
1068    }
1069
1070    #[test]
1071    fn rubber_band_amortigua() {
1072        let dim = 600.0;
1073        // Siempre menor en magnitud que el overscroll crudo.
1074        assert!(rubber_band(100.0, dim) < 100.0);
1075        assert!(rubber_band(100.0, dim) > 0.0);
1076        // Conserva el signo.
1077        assert!(rubber_band(-80.0, dim) < 0.0);
1078        // Rendimiento decreciente: estirar 2× no duplica el desplazamiento.
1079        let a = rubber_band(100.0, dim);
1080        let b = rubber_band(200.0, dim);
1081        assert!(b > a && b < 2.0 * a);
1082        // Cerca de 0 es casi lineal (poca amortiguación todavía).
1083        assert!(rubber_band(0.0, dim).abs() < 1e-6);
1084    }
1085
1086    #[test]
1087    fn sliver_colapso_y_max() {
1088        // Header 200→64, viewport 500, contenido 1200.
1089        let (max_h, min_h) = (200.0, 64.0);
1090        // Offset 0 → expandido, frac 0.
1091        assert_eq!(collapsed_height(0.0, max_h, min_h), 200.0);
1092        assert_eq!(collapse_fraction(0.0, max_h, min_h), 0.0);
1093        // A mitad del rango (68px de 136) → ~0.5 y altura ~132.
1094        let mid = (max_h - min_h) / 2.0; // 68
1095        assert!((collapse_fraction(mid, max_h, min_h) - 0.5).abs() < 1e-3);
1096        assert!((collapsed_height(mid, max_h, min_h) - 132.0).abs() < 1e-3);
1097        // Pasado el rango → fijado al mínimo, frac 1.
1098        assert_eq!(collapsed_height(500.0, max_h, min_h), 64.0);
1099        assert_eq!(collapse_fraction(500.0, max_h, min_h), 1.0);
1100        // Max offset = rango (136) + scroll del cuerpo bajo el header mínimo.
1101        let body_vp = 500.0 - min_h; // 436
1102        let expected = 136.0 + max_offset(1200.0, body_vp);
1103        assert!((sliver_max_offset(1200.0, 500.0, max_h, min_h) - expected).abs() < 1e-3);
1104    }
1105
1106    #[test]
1107    fn sticky_pegado_y_empujado() {
1108        // Sección [100, 100+300], encabezado 40px de alto.
1109        let (top, sh, hh) = (100.0, 300.0, 40.0);
1110        // Antes de llegar al tope (offset 50 < 100): posición natural 50.
1111        assert_eq!(sticky_y(50.0, top, sh, hh), 50.0);
1112        // Dentro de la sección (offset 200 > top): pegado al tope (0).
1113        assert_eq!(sticky_y(200.0, top, sh, hh), 0.0);
1114        // Cerca del fondo de la sección: la próxima lo empuja hacia arriba (<0).
1115        // section_bottom - hh = (100+300-380) - 40 = -20.
1116        assert_eq!(sticky_y(380.0, top, sh, hh), -20.0);
1117    }
1118
1119    #[test]
1120    fn thumb_proporcional_y_topes() {
1121        // Contenido entra entero → thumb cubre todo, sin travel.
1122        let (h, y, opp) = thumb_geometry(0.0, 200.0, 300.0);
1123        assert_eq!((h, y, opp), (300.0, 0.0, 0.0));
1124        // Contenido 3× viewport → thumb ≈ 1/3 (clampeado a MIN_THUMB).
1125        let (h, y, _) = thumb_geometry(0.0, 900.0, 300.0);
1126        assert!((h - 100.0).abs() < 0.01);
1127        assert_eq!(y, 0.0);
1128        // En el máximo offset, el thumb toca el fondo del track.
1129        let max = max_offset(900.0, 300.0);
1130        let (h2, y2, _) = thumb_geometry(max, 900.0, 300.0);
1131        assert!((y2 + h2 - 300.0).abs() < 0.01);
1132    }
1133}