tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
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}