tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ dba855e446ca2bd4e7d146bdc168fb232cad15a4 widgets/scroll/src/lib.rs Rust · 890 líneas · 35.2 KB Historia Quién tocó qué crudo

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