//! `llimphi-widget-scroll` — área de scroll vertical reutilizable. //! //! Hasta ahora cada app rearmaba el scroll a mano: `App::on_wheel` + un //! offset en el `Model` + `clip` + virtualización. Este widget empaqueta //! ese patrón en un solo builder, **sin estado propio** (el offset sigue //! viviendo en el `Model`, fiel al bucle Elm): //! //! - **viewport clipeado** de alto fijo (`viewport_len`), //! - **contenido desplazado** `-offset` px (overflow recortado), //! - **barra de scroll arrastrable** a la derecha (sólo si el contenido //! excede el viewport), //! - **rueda autocontenida** vía [`View::on_scroll`]: girar la rueda con //! el cursor sobre el área emite un `Msg` sin que la app rutee nada por //! su `on_wheel` global. //! //! El caller debe conocer el **alto total del contenido** (`content_len`) //! y el **alto visible** (`viewport_len`) — igual que `list`/`grid` ya //! piden la ventana visible. Para contenido de filas uniformes es //! `n_filas * alto_fila`. //! //! ## Convención del callback `on_scroll` //! //! `on_scroll` recibe el **delta en px** a sumar al offset (no el offset //! absoluto): tanto la rueda como el arrastre de la barra emiten deltas, //! y el caller acumula + clampea en su `update` con [`clamp_offset`]. Es //! la misma idea que el `splitter` (el handler de drag se reusa durante //! todo el arrastre, así que un offset absoluto capturado se quedaría //! viejo; el delta-por-evento siempre es correcto). //! //! ```ignore //! // view: //! scroll_y( //! model.offset, //! model.rows.len() as f32 * ROW_H, //! panel_h, //! lista_view, //! Msg::ScrollBy, // Fn(f32) -> Msg, arg = delta px //! &ScrollPalette::default(), //! ) //! // update: //! Msg::ScrollBy(d) => { //! m.offset = clamp_offset(m.offset + d, content_len, viewport_len); //! } //! ``` //! //! Para llevar una selección a la vista (teclado), ver [`ensure_visible`]; //! para scroll suave/inercia, ver [`approach`]. #![forbid(unsafe_code)] // Sin `std`: este widget no usa nada del sistema —sólo `alloc` para // `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula // WASM o al kernel. Antes enlazaba `std` por omisión (W4.6). #![no_std] extern crate alloc; use alloc::vec; use alloc::sync::Arc; use llimphi_compositor::llimphi_layout::taffy::{ prelude::{auto, length, percent, Position, Rect, Size, Style}, }; use llimphi_compositor::peniko::Color; use llimphi_compositor::{DragPhase, View}; /// Alto mínimo del thumb en px — para que no desaparezca con contenido /// muy largo. const MIN_THUMB: f32 = 28.0; /// Px de desplazamiento por "línea" de rueda. Aproxima el step de scroll /// de un editor (≈3 líneas de texto). pub const DEFAULT_LINE_PX: f32 = 48.0; /// Ancho de la barra de scroll en px. pub const DEFAULT_BAR_WIDTH: f32 = 10.0; /// Factor de alpha por defecto para el thumb en reposo — tenue moderno /// estilo Chromium/Edge/Safari: visible pero discreto. Al hover sobre la /// barra recupera alpha completo. `1.0` reproduce el comportamiento /// histórico (thumb siempre opaco). pub const DEFAULT_THUMB_IDLE_ALPHA: f32 = 0.55; /// Colores de la barra de scroll. #[derive(Debug, Clone, Copy)] pub struct ScrollPalette { /// Canal de fondo (track). pub track: Color, /// Pulgar (thumb) en reposo. pub thumb: Color, /// Pulgar al pasar el cursor. pub thumb_hover: Color, /// Ancho de la barra y px por línea de rueda. pub bar_width: f32, pub line_px: f32, /// Multiplicador de alpha aplicado al `thumb` en reposo. `1.0` deja /// el color sin tocar (comportamiento legacy); `≤ 0.0` esconde el /// thumb del todo en reposo. El `hover_fill` no se ve afectado: al /// pasar el cursor sobre la barra el thumb recupera el alpha completo /// del `thumb_hover`. pub thumb_idle_alpha: f32, } impl Default for ScrollPalette { fn default() -> Self { Self::from_theme(&llimphi_theme::Theme::dark()) } } impl ScrollPalette { pub fn from_theme(t: &llimphi_theme::Theme) -> Self { Self { track: t.bg_panel_alt, thumb: t.border, thumb_hover: t.accent, bar_width: DEFAULT_BAR_WIDTH, line_px: DEFAULT_LINE_PX, thumb_idle_alpha: DEFAULT_THUMB_IDLE_ALPHA, } } /// Devuelve la `ScrollPalette` con el comportamiento histórico (thumb /// opaco en reposo, sin auto-hide visual). Para apps que dependen del /// look anterior al cambio del 2026-06-07. pub fn opaque(mut self) -> Self { self.thumb_idle_alpha = 1.0; self } } /// Máximo offset posible: cuánto se puede desplazar antes de que el final /// del contenido toque el borde inferior del viewport. `0` si el contenido /// entra entero. pub fn max_offset(content_len: f32, viewport_len: f32) -> f32 { (content_len - viewport_len).max(0.0) } /// Acota `offset` a `[0, max_offset]`. El caller lo usa en su `update` /// tras sumar el delta de [`scroll_y`]. pub fn clamp_offset(offset: f32, content_len: f32, viewport_len: f32) -> f32 { offset.clamp(0.0, max_offset(content_len, viewport_len)) } /// Publica el alto **medido** de un área de scroll: el widget lo escribe /// cuando se pinta y el host lo lee en el cuadro siguiente para pasarlo como /// `viewport_len`. /// /// Existe porque el alto disponible **no se sabe** hasta que taffy repartió, o /// sea después de armar el `View`. Un host que ya conoce el alto de su panel /// puede pasarlo directo y no usar esto; el resto paga un cuadro de latencia en /// el primero y en cada resize, que es lo que hace cualquier lista con scroll. #[derive(Debug, Clone, Default)] pub struct AltoVisible(Arc); impl AltoVisible { pub fn new() -> Self { Self::default() } /// Alto (px) del último cuadro pintado; `0.0` si todavía no se pintó. pub fn px(&self) -> f32 { f32::from_bits(self.0.load(core::sync::atomic::Ordering::Relaxed)) } /// Lo escribe el widget desde su `paint_with`. Público porque el área la /// compone cada widget con su propio layout. pub fn escribir(&self, v: f32) { self.0.store(v.max(0.0).to_bits(), core::sync::atomic::Ordering::Relaxed); } } /// Lo mismo que [`AltoVisible`] pero de **los dos ejes**, para las áreas que /// scrollean en 2D ([`scroll_xy`]). /// /// Un área horizontal necesita saber su ancho por el mismo motivo por el que la /// vertical necesita su alto: sin viewport no hay tope que clampear ni pulgar /// que dimensionar, y el ancho tampoco se sabe hasta que taffy repartió. Medir /// los dos con dos `AltoVisible` sueltos funcionaría, pero uno de ellos se /// llamaría "alto" y guardaría un ancho. #[derive(Debug, Clone, Default)] pub struct TamanoVisible { ancho: AltoVisible, alto: AltoVisible, } impl TamanoVisible { pub fn new() -> Self { Self::default() } /// `(ancho, alto)` del último cuadro pintado; `(0.0, 0.0)` si todavía no se /// pintó. pub fn px(&self) -> (f32, f32) { (self.ancho.px(), self.alto.px()) } /// Lo escribe el widget desde su `paint_with`, con el rect que le tocó. pub fn escribir(&self, ancho: f32, alto: f32) { self.ancho.escribir(ancho); self.alto.escribir(alto); } } /// El desplazamiento con el que **pintar** el contenido. Sin alto medido /// todavía (`viewport_len <= 0`, el primer cuadro) se pinta desde arriba. /// /// No es una precaución de más: [`clamp_offset`] con viewport `0` da por bueno /// **cualquier** offset —el sobrante le parece el contenido entero—, así que /// una lista que se re-monta con un offset guardado arrancaría desplazada a un /// lugar que todavía no puede validar, y saltaría en el cuadro siguiente. /// Mejor un cuadro desde arriba que un salto. pub fn offset_efectivo(offset: f32, viewport_len: f32) -> f32 { if viewport_len <= 0.0 { 0.0 } else { offset.max(0.0) } } /// Scroll vertical de un cuerpo, tal como lo piden los widgets de lista /// (`list`, `tree`, `detail-table`): el estado vive en el host y esto es lo que /// hay que pasarle al widget para que el cuerpo se desplace. pub struct DesplazamientoV { /// Offset actual (px). El host lo clampea con [`clamp_offset`]. pub offset: f32, /// Alto visible. `0.0` = todavía sin medir (ver [`offset_efectivo`]). pub viewport_len: f32, /// Rueda / arrastre de la barra: delta en px a sumar al `offset`. pub on_scroll: Arc Msg + Send + Sync>, } /// Envuelve `contenido` (de alto `contenido_len`) en un área scrolleable que /// **toma el alto sobrante** de su padre y, si se le da un [`AltoVisible`], /// publica cuánto resultó ser. /// /// Es la pieza que comparten los widgets de lista para no reimplementar cada /// uno su scroll: rueda, barra arrastrable, clamp y fling ya viven en /// [`scroll_y`], y lo único que agregaban por su cuenta era el pegamento de /// layout y la medición. pub fn cuerpo_scrolleable( contenido: View, contenido_len: f32, d: DesplazamientoV, medidor: Option, palette: &ScrollPalette, ) -> View { let DesplazamientoV { offset, viewport_len, on_scroll } = d; let area = scroll_y( offset_efectivo(offset, viewport_len), contenido_len, viewport_len, contenido, move |dy: f32| (on_scroll)(dy), palette, ); let caja = View::new(Style { flex_direction: llimphi_compositor::llimphi_layout::taffy::prelude::FlexDirection::Column, flex_grow: 1.0, flex_basis: length(0.0_f32), size: Size { width: percent(1.0_f32), height: llimphi_compositor::llimphi_layout::taffy::prelude::auto(), }, // Sin este min-height un hijo alto empuja al flex y el área deja de // recortar: el cuerpo crece en vez de scrollear. min_size: Size { width: llimphi_compositor::llimphi_layout::taffy::prelude::auto(), height: length(0.0_f32), }, ..Default::default() }) .children(vec![area]); match medidor { Some(m) => caja.paint_with(move |_s, _ts, rect| m.escribir(rect.h)), None => caja, } } /// Devuelve el offset que deja **visible** el intervalo vertical /// `[item_top, item_top + item_h]` dentro de un viewport de alto /// `viewport_len`, partiendo de `offset`. Si ya está visible, lo devuelve /// sin cambios. Pensado para "llevar la selección a la vista" al navegar /// con teclado (flechas, Page Up/Down). El resultado se acota a `≥ 0`; el /// caller puede clampear arriba con [`clamp_offset`] si lo necesita. pub fn ensure_visible(offset: f32, viewport_len: f32, item_top: f32, item_h: f32) -> f32 { if item_top < offset { // El item arranca por encima del viewport: subí hasta su tope. item_top.max(0.0) } else if item_top + item_h > offset + viewport_len { // El item termina por debajo: baja hasta que su fondo toque el borde. (item_top + item_h - viewport_len).max(0.0) } else { offset } } /// Un paso de aproximación exponencial de `current` hacia `target` /// (scroll suave / inercia). `factor ∈ (0, 1]`: 1.0 salta de una, 0.2 /// desliza suave. Cuando la diferencia cae por debajo de 0.5 px aterriza /// exacto en `target` (evita el "casi-llega" infinito). El caller lo /// dispara por frame vía `Handle::spawn_periodic` guardando `target` en /// su `Model`. pub fn approach(current: f32, target: f32, factor: f32) -> f32 { let f = factor.clamp(0.0, 1.0); let next = current + (target - current) * f; if (target - next).abs() < 0.5 { target } else { next } } /// Velocidad (px/s) por debajo de la cual una inercia se considera detenida. pub const FLING_STOP: f32 = 8.0; /// Fricción por defecto del fling: fracción de velocidad que **sobrevive por /// segundo** (más chico = frena antes). 0.0015 ≈ deslizamiento tipo lista /// táctil; subilo (p. ej. 0.1) para frenar rápido. pub const FLING_FRICTION: f32 = 0.0015; /// Un paso de **inercia** (fling): dado `velocity` en px/s y `dt` en segundos, /// devuelve `(nueva_velocidad, delta_offset)` bajo decaimiento exponencial /// `v(t) = v·friction^t`. `friction ∈ (0,1]` es la fracción de velocidad que /// sobrevive por segundo. El `delta` es la integral exacta de la velocidad /// sobre el paso (no el rectángulo `v·dt`), así el frenado no depende del /// frame-rate. El caller suma `delta` al offset (clampeando con /// [`clamp_offset`]) y reusa `nueva_velocidad` el próximo frame hasta que /// [`fling_settled`] dé `true`. Es el análogo de [`approach`] pero para /// "soltar con envión" en vez de "ir hacia un objetivo". pub fn fling_step(velocity: f32, dt: f32, friction: f32) -> (f32, f32) { let f = friction.clamp(1e-6, 1.0); let decay = powf(f, dt.max(0.0)); let new_v = velocity * decay; let delta = if (f - 1.0).abs() < 1e-6 { velocity * dt } else { // ∫₀^dt v·f^s ds = v·(f^dt − 1)/ln f. velocity * (decay - 1.0) / ln(f) }; (new_v, delta) } // `powf` y `ln` son métodos de la stdlib, no operaciones del lenguaje: sin // `std` (la jaula, el kernel) no existen y van por libm. #[inline] fn powf(x: f32, y: f32) -> f32 { #[cfg(feature = "std")] { x.powf(y) } #[cfg(not(feature = "std"))] { libm::powf(x, y) } } #[inline] fn ln(x: f32) -> f32 { #[cfg(feature = "std")] { x.ln() } #[cfg(not(feature = "std"))] { libm::logf(x) } } /// ¿La inercia ya se detuvo? `true` cuando `|velocity| < FLING_STOP` — el /// caller corta el ticker y deja el offset quieto. pub fn fling_settled(velocity: f32) -> bool { velocity.abs() < FLING_STOP } /// Resistencia elástica (rubber-band) al **sobrepasar un borde**, estilo iOS: /// dado cuánto se pasó del límite (`overscroll`, px; el signo se conserva) y la /// dimensión del viewport (`dim`), devuelve el desplazamiento visual /// **amortiguado** — siempre menor en magnitud que `overscroll`, con /// rendimiento decreciente cuanto más se estira. El caller lo usa para pintar /// el contenido un poco más allá del tope mientras arrastra, y lo libera /// (anima a 0 con [`approach`]) al soltar. Constante 0.55 = la de Apple. pub fn rubber_band(overscroll: f32, dim: f32) -> f32 { if dim <= 0.0 || overscroll == 0.0 { return overscroll; } const C: f32 = 0.55; let x = overscroll.abs(); (1.0 - 1.0 / (x * C / dim + 1.0)) * dim * overscroll.signum() } // ── Auto-hide del thumb (timer-driven, lo maneja la app) ── /// Segundos que el thumb queda a opacidad plena tras la última interacción de /// scroll antes de empezar a desvanecerse. pub const THUMB_HOLD_SECS: f32 = 1.2; /// Segundos que tarda el thumb en desvanecerse de pleno a invisible. pub const THUMB_FADE_SECS: f32 = 0.4; /// Opacidad del thumb en función de los segundos desde la última interacción /// de scroll (auto-hide estilo overlay móvil/Chromium): pleno (`1.0`) durante /// [`THUMB_HOLD_SECS`], luego baja linealmente a `0.0` en [`THUMB_FADE_SECS`]. /// /// El bucle Elm reconstruye el `View` sin estado retenido, así que el timer lo /// lleva la app (igual que el fling): trackea `last_scroll: Instant` en el /// Model, reseteándolo en cada `on_scroll`, y antes de llamar a /// `scroll_y`/`scroll_xy` haz `palette.thumb_idle_alpha = /// thumb_autohide_alpha(last_scroll.elapsed().as_secs_f32())`. Mientras /// [`thumb_autohide_active`] sea `true`, pedí frames (`Handle::spawn_periodic`). /// En reposo el thumb queda invisible pero su `hover_fill` lo revela al pasar /// el cursor por el borde. pub fn thumb_autohide_alpha(secs_since_scroll: f32) -> f32 { if secs_since_scroll <= THUMB_HOLD_SECS { return 1.0; } let fade = (secs_since_scroll - THUMB_HOLD_SECS) / THUMB_FADE_SECS.max(1e-3); (1.0 - fade).clamp(0.0, 1.0) } /// `true` mientras el thumb sigue desvaneciéndose (la app debe seguir pidiendo /// frames). Una vez invisible (pasado hold+fade), devuelve `false` y la app /// puede dejar de tickear. pub fn thumb_autohide_active(secs_since_scroll: f32) -> bool { secs_since_scroll < THUMB_HOLD_SECS + THUMB_FADE_SECS } // ── Pull-to-refresh (compone sobre el overscroll que ya maneja la app) ── /// Distancia de overscroll (px) a la que el pull-to-refresh queda **armado** /// por defecto: soltar más allá dispara el refresh. pub const DEFAULT_PULL_THRESHOLD: f32 = 64.0; /// Progreso del gesto pull-to-refresh en `[0,1]`: qué fracción del umbral /// cubre el `overscroll` actual en el tope (distancia que el contenido fue /// arrastrado más allá del borde superior — la app ya la computa para el /// [`rubber_band`]). Alimenta el barrido de [`pull_indicator_view`]. pub fn pull_progress(overscroll_px: f32, threshold_px: f32) -> f32 { if threshold_px <= 0.0 { return 0.0; } (overscroll_px / threshold_px).clamp(0.0, 1.0) } /// `true` si el overscroll alcanzó el umbral — soltar en este punto dispara el /// refresh. La app lo chequea en `DragPhase::End`/al asentar el rubber-band. pub fn pull_triggered(overscroll_px: f32, threshold_px: f32) -> bool { threshold_px > 0.0 && overscroll_px >= threshold_px } // ── Slivers: app-bar colapsable + sticky headers (seam "extent-por-offset") ── /// Altura de un **app-bar colapsable** dado el `offset` de scroll: arranca en /// `header_max` (offset 0) y baja linealmente hasta `header_min`, donde queda /// fijado (pinned). El "rango de colapso" es `header_max - header_min`. pub fn collapsed_height(offset: f32, header_max: f32, header_min: f32) -> f32 { (header_max - offset.max(0.0)).clamp(header_min, header_max) } /// Fracción de colapso del app-bar en `[0, 1]`: `0` = expandido (offset 0), /// `1` = colapsado al mínimo. El caller la usa para fundir el título, achicar /// un subtítulo, bajar la opacidad de una imagen de fondo, etc. pub fn collapse_fraction(offset: f32, header_max: f32, header_min: f32) -> f32 { let range = (header_max - header_min).max(0.0); if range <= 0.0 { return 1.0; } (offset.max(0.0) / range).clamp(0.0, 1.0) } /// Offset máximo de scroll con un app-bar colapsable: los `header_max - /// header_min` px que consume el colapso **más** lo que scrollee el cuerpo /// bajo el header ya fijado en `header_min`. El caller lo usa para clampear. pub fn sliver_max_offset( content_len: f32, viewport_len: f32, header_max: f32, header_min: f32, ) -> f32 { let range = (header_max - header_min).max(0.0); let body_vp = (viewport_len - header_min).max(0.0); range + max_offset(content_len, body_vp) } /// Posición `y` (relativa al tope del viewport) de un encabezado **sticky** de /// una sección que ocupa `[section_top, section_top + section_h]` en /// coordenadas de contenido, con altura de encabezado `header_h`. Mientras la /// sección está en pantalla, el encabezado se **pega al tope** (`y = 0`); al /// llegar la próxima sección, ésta lo **empuja** hacia arriba (no pasa de /// `section_bottom - header_h`). Antes de que la sección llegue al tope, sigue /// su posición natural. El caller posiciona el encabezado absoluto en esta `y`. /// Cuánto de un `delta` (px, `+` = hacia abajo) puede **consumir** un área con /// `offset` actual y `max_off`: el tramo que le queda hasta el tope en esa /// dirección. Devuelve `(consumido, sobrante)`, ambos con el signo del delta. /// Base del reparto parcial del scroll anidado (`View::on_scroll_take`): el /// sobrante sigue al ancestro scrollable en el mismo evento. pub fn scroll_take(delta: f32, offset: f32, max_off: f32) -> (f32, f32) { let can = if delta > 0.0 { delta.min((max_off - offset).max(0.0)) } else if delta < 0.0 { delta.max(-offset.max(0.0)) } else { 0.0 }; (can, delta - can) } pub fn sticky_y(offset: f32, section_top: f32, section_h: f32, header_h: f32) -> f32 { let natural = section_top - offset; // y del encabezado sin sticky let section_bottom = section_top + section_h - offset; natural.max(0.0).min(section_bottom - header_h) } /// Geometría del thumb: `(altura, posición_y)` dentro del track de alto /// `viewport_len`, y `offset_por_px` (cuánto offset de contenido equivale /// a 1 px de arrastre del thumb). Público para tests y para callers que /// quieran pintar su propia barra. pub fn thumb_geometry(offset: f32, content_len: f32, viewport_len: f32) -> (f32, f32, f32) { let max_off = max_offset(content_len, viewport_len); if max_off <= 0.0 || content_len <= 0.0 { return (viewport_len, 0.0, 0.0); } let ratio = (viewport_len / content_len).clamp(0.0, 1.0); let thumb_h = (viewport_len * ratio).clamp(MIN_THUMB.min(viewport_len), viewport_len); let travel = (viewport_len - thumb_h).max(0.0); let thumb_y = if max_off > 0.0 { (offset / max_off).clamp(0.0, 1.0) * travel } else { 0.0 }; let offset_per_px = if travel > 0.0 { max_off / travel } else { 0.0 }; (thumb_h, thumb_y, offset_per_px) } /// Delta de offset para un **click en el canal** de la barra (page-jump, como /// un scrollbar de escritorio): si el click cae **antes** del thumb, sube una /// página (`-viewport·0.9`); si cae **después**, baja una. `0` si cae sobre el /// thumb (ese click lo maneja el drag). Pura y testeable; el caller la suma al /// offset con [`clamp_offset`]. pub fn track_page_jump(click_y: f32, thumb_y: f32, thumb_h: f32, viewport_len: f32) -> f32 { let page = viewport_len * 0.9; if click_y < thumb_y { -page } else if click_y > thumb_y + thumb_h { page } else { 0.0 } } /// Área de scroll vertical. `offset` es el desplazamiento actual (px, ya /// clampeado por el caller). `content_len`/`viewport_len` el alto total y /// visible. `content` se desplaza `-offset` y se recorta al viewport. /// `on_scroll(delta_px)` se invoca con el delta a sumar al offset (rueda /// y arrastre de barra); el caller acumula con [`clamp_offset`]. pub fn scroll_y( offset: f32, content_len: f32, viewport_len: f32, content: View, on_scroll: F, palette: &ScrollPalette, ) -> View where // `Msg` no necesita `Send + Sync`: los closures de rueda/arrastre // capturan el `Arc`, no un `Msg`. Sólo se exige // `Clone` (para montar el `View`) y `'static`. Msg: Clone + 'static, F: Fn(f32) -> Msg + Send + Sync + 'static, { let on_scroll = Arc::new(on_scroll); // Contenido desplazado: nodo absoluto anclado a left/right (toma el // ancho del viewport) con top = -offset y alto natural. El overflow se // recorta por el `clip` del viewport. let content_wrap = View::new(Style { position: Position::Absolute, inset: Rect { top: length(-offset), left: length(0.0_f32), right: length(0.0_f32), bottom: auto(), }, ..Default::default() }) .children(vec![content]); let mut children = vec![content_wrap]; // Barra: sólo si hay overflow. if max_offset(content_len, viewport_len) > 0.0 { let (thumb_h, thumb_y, offset_per_px) = thumb_geometry(offset, content_len, viewport_len); let on_thumb = on_scroll.clone(); let thumb = View::new(Style { position: Position::Absolute, inset: Rect { top: length(thumb_y), right: length(0.0_f32), left: auto(), bottom: auto(), }, size: Size { width: length(palette.bar_width), height: length(thumb_h), }, ..Default::default() }) .fill(palette.thumb.multiply_alpha(palette.thumb_idle_alpha.clamp(0.0, 1.0))) .hover_fill(palette.thumb_hover) .radius((palette.bar_width * 0.5) as f64) .draggable(move |phase, _dx, dy| match phase { // Cada Move trae el delta de px de pantalla del thumb; lo // convertimos a delta de offset de contenido. DragPhase::Move => Some((on_thumb)(dy * offset_per_px)), DragPhase::End => None, }); // Click en el canal (fuera del thumb) = salto de página hacia el click. let on_track = on_scroll.clone(); let track = View::new(Style { position: Position::Absolute, inset: Rect { top: length(0.0_f32), right: length(0.0_f32), bottom: length(0.0_f32), left: auto(), }, size: Size { width: length(palette.bar_width), height: auto(), }, ..Default::default() }) .fill(palette.track) // El canal de la barra: se clickea para saltar de página, así que es un // nodo activable y necesita decir qué es. Era el mudo que aparecía en // todo panel con scroll (los menús XP y GNOME de pata, entre otros). .role(llimphi_compositor::Role::Slider) .aria_label("Barra de desplazamiento") .on_click_at(move |_lx, ly, _w, _h| { let delta = track_page_jump(ly, thumb_y, thumb_h, viewport_len); if delta != 0.0 { Some((on_track)(delta)) } else { None } }) .children(vec![thumb]); children.push(track); } // Viewport: alto fijo, ancho del padre, contenido recortado, rueda // local. Position::Relative para ser el bloque contenedor de los // hijos absolutos. // // **Scroll anidado**: si el delta del eje vertical es hacia un extremo // donde ya estamos topados (offset = 0 con dy<0, u offset = max con // dy>0), devolvemos `None` para que el runtime propague el evento al // ancestro scrollable más cercano (lista dentro de panel, etc.). let line_px = palette.line_px; let on_wheel = on_scroll; let max_off = max_offset(content_len, viewport_len); View::new(Style { position: Position::Relative, size: Size { width: percent(1.0_f32), height: length(viewport_len), }, ..Default::default() }) .clip(true) // Reparto parcial: consume hasta el tope y devuelve el sobrante (en // líneas) para que siga al ancestro scrollable en el mismo evento. El // eje x pasa entero (esta área es vertical). .on_scroll_take(move |dx, dy| { let (can, rest) = scroll_take(dy * line_px, offset, max_off); let msg = (can.abs() > f32::EPSILON).then(|| (on_wheel)(can)); (msg, dx, rest / line_px) }) .children(children) } /// Área de scroll **2D** (horizontal + vertical). Generaliza [`scroll_y`] a dos /// ejes: el contenido toma su tamaño natural y se desplaza `(-x, -y)`, recortado /// al viewport; aparece una barra por eje que tenga overflow (ninguna, una o /// las dos). Para scroll puramente horizontal, pasa `content_size.1 == /// viewport_size.1` (no sale barra vertical). /// /// `on_scroll(dx, dy)` recibe el **delta en px por eje** a sumar a cada offset /// (rueda → ambos ejes; arrastre de la barra vertical → sólo `dy`; horizontal → /// sólo `dx`). El caller acumula y clampea cada eje con [`clamp_offset`]. Las /// dos barras se solapan en una esquinita inferior-derecha (v1; cosmético). pub fn scroll_xy( offset: (f32, f32), content_size: (f32, f32), viewport_size: (f32, f32), content: View, on_scroll: F, palette: &ScrollPalette, ) -> View where Msg: Clone + 'static, F: Fn(f32, f32) -> Msg + Send + Sync + 'static, { let (ox, oy) = offset; let (cw, ch) = content_size; let (vw, vh) = viewport_size; let on_scroll = Arc::new(on_scroll); // Contenido a tamaño natural, desplazado (-x, -y). right/bottom = auto para // que no lo achique el viewport (a diferencia de scroll_y, que ancla // left/right para tomar el ancho del viewport). let content_wrap = View::new(Style { position: Position::Absolute, inset: Rect { top: length(-oy), left: length(-ox), right: auto(), bottom: auto(), }, ..Default::default() }) .children(vec![content]); let mut children = vec![content_wrap]; // Barra vertical (borde derecho) — sólo si hay overflow vertical. if max_offset(ch, vh) > 0.0 { let (thumb_h, thumb_y, opp) = thumb_geometry(oy, ch, vh); let f = on_scroll.clone(); let thumb = View::new(Style { position: Position::Absolute, inset: Rect { top: length(thumb_y), right: length(0.0_f32), left: auto(), bottom: auto() }, size: Size { width: length(palette.bar_width), height: length(thumb_h) }, ..Default::default() }) .fill(palette.thumb.multiply_alpha(palette.thumb_idle_alpha.clamp(0.0, 1.0))) .hover_fill(palette.thumb_hover) .radius((palette.bar_width * 0.5) as f64) .draggable(move |phase, _dx, dy| match phase { DragPhase::Move => Some((f)(0.0, dy * opp)), DragPhase::End => None, }); let track = View::new(Style { position: Position::Absolute, inset: Rect { top: length(0.0_f32), right: length(0.0_f32), bottom: length(0.0_f32), left: auto() }, size: Size { width: length(palette.bar_width), height: auto() }, ..Default::default() }) .fill(palette.track) .children(vec![thumb]); children.push(track); } // Barra horizontal (borde inferior) — sólo si hay overflow horizontal. if max_offset(cw, vw) > 0.0 { let (thumb_w, thumb_x, opp) = thumb_geometry(ox, cw, vw); let f = on_scroll.clone(); let thumb = View::new(Style { position: Position::Absolute, inset: Rect { left: length(thumb_x), bottom: length(0.0_f32), top: auto(), right: auto() }, size: Size { width: length(thumb_w), height: length(palette.bar_width) }, ..Default::default() }) .fill(palette.thumb.multiply_alpha(palette.thumb_idle_alpha.clamp(0.0, 1.0))) .hover_fill(palette.thumb_hover) .radius((palette.bar_width * 0.5) as f64) .draggable(move |phase, dx, _dy| match phase { DragPhase::Move => Some((f)(dx * opp, 0.0)), DragPhase::End => None, }); let track = View::new(Style { position: Position::Absolute, inset: Rect { left: length(0.0_f32), right: length(0.0_f32), bottom: length(0.0_f32), top: auto() }, size: Size { width: auto(), height: length(palette.bar_width) }, ..Default::default() }) .fill(palette.track) .children(vec![thumb]); children.push(track); } // Scroll anidado 2D: si el delta NETO está bloqueado en ambos ejes // (cada componente cae en un extremo del eje correspondiente), // devolvemos `None` para propagar al ancestro scrollable. Si al menos // un eje aún tiene recorrido, el evento se consume entero (como antes). let line_px = palette.line_px; let on_wheel = on_scroll; let max_ox = max_offset(cw, vw); let max_oy = max_offset(ch, vh); View::new(Style { position: Position::Relative, size: Size { width: length(vw), height: length(vh) }, ..Default::default() }) .clip(true) // Rueda: dy = eje vertical; dx = eje horizontal (ratones/touchpads 2D, o // Shift+rueda en algunos backends). Ambos en px-línea. .on_scroll_take(move |dx, dy| { // Reparto parcial por eje: cada uno consume hasta su tope y el // sobrante (en líneas) sigue al ancestro en el mismo evento. let (cx, rx) = scroll_take(dx * line_px, ox, max_ox); let (cy, ry) = scroll_take(dy * line_px, oy, max_oy); let msg = (cx.abs() > f32::EPSILON || cy.abs() > f32::EPSILON) .then(|| (on_wheel)(cx, cy)); (msg, rx / line_px, ry / line_px) }) .children(children) } /// Indicador circular del **pull-to-refresh**. Mientras el usuario arrastra /// más allá del tope, un arco se completa con `progress` (0→1, de /// [`pull_progress`]); al alcanzar 1 el círculo queda cerrado ("armado"). Con /// `refreshing = true` gira como spinner (arco de 270° rotando por reloj /// absoluto — pedí frames mientras dure). `size_px` es el diámetro. /// /// La app lo posiciona en la zona de overscroll del tope (típico: centrado /// arriba, bajando junto con el contenido arrastrado). Es puro paint; no /// retiene estado. // GATEADO A `std`: esta vista arranca su reloj con `Instant::now()`, que en el // perfil sin sistema NO EXISTE a propósito — allá el instante del fotograma // entra por argumento, y un uso equivocado debe ser un error de compilación, no // una animación congelada. #[cfg(feature = "std")] pub fn pull_indicator_view( progress: f32, refreshing: bool, size_px: f32, palette: &ScrollPalette, ) -> View { let p = progress.clamp(0.0, 1.0); let arc_color = palette.thumb; let track_color = palette.track; let started = llimphi_compositor::Instant::now(); View::new(Style { size: Size { width: length(size_px), height: length(size_px), }, flex_shrink: 0.0, ..Default::default() }) .paint_with(move |scene, _ts, rect| { use llimphi_compositor::peniko::kurbo::{Affine, Arc, Point, Shape, Stroke, Vec2}; use core::f64::consts::TAU; if rect.w <= 0.0 || rect.h <= 0.0 { return; } let d = (rect.w.min(rect.h)) as f64; let stroke_w = (d * 0.1).clamp(1.5, 4.0); let r = d * 0.5 - stroke_w; if r <= 0.0 { return; } let cx = (rect.x + rect.w * 0.5) as f64; let cy = (rect.y + rect.h * 0.5) as f64; let center = Point::new(cx, cy); let radii = Vec2::new(r, r); let stroke = Stroke::new(stroke_w); // Anillo de fondo tenue (track). let ring = Arc::new(center, radii, 0.0, TAU, 0.0).to_path(0.2); scene.stroke(&stroke, Affine::IDENTITY, track_color, None, &ring); // Arco activo: arranca arriba (12 en punto = -PI/2). Si refresca, gira // un arco de 270°; si no, barre proporcional al progreso. let top = -core::f64::consts::FRAC_PI_2; let (start, sweep) = if refreshing { let spin = started.elapsed().as_secs_f64() * 3.0; // rad/s (top + spin, TAU * 0.75) } else { (top, TAU * p as f64) }; if sweep.abs() > 1e-3 { let active = Arc::new(center, radii, start, sweep, 0.0).to_path(0.2); scene.stroke(&stroke, Affine::IDENTITY, arc_color, None, &active); } }) } /// **App-bar colapsable + cuerpo scrolleable** en un solo viewport (el sliver /// más pedido). Un único `offset` (en el Model) maneja las dos cosas: primero /// **colapsa** el header de `header_max` a `header_min` (consume los primeros /// `header_max - header_min` px de scroll), y luego **scrollea** el cuerpo bajo /// el header ya fijado en `header_min`. /// /// `header(frac)` construye el contenido del header dado `frac ∈ [0,1]` (ver /// [`collapse_fraction`]) — el caller lo usa para fundir el título, mostrar una /// versión compacta al colapsar, etc. El header se pinta a la altura /// [`collapsed_height`] del momento. /// /// `content_len` es el alto natural del cuerpo; el viewport del cuerpo cambia /// con el colapso (crece a medida que el header se achica). La rueda funciona /// tanto sobre el header como sobre el cuerpo (ambos emiten `on_scroll`). El /// caller clampea el offset con [`sliver_max_offset`]. pub fn sliver_app_bar( offset: f32, header_max: f32, header_min: f32, header: H, content: View, content_len: f32, viewport_len: f32, on_scroll: F, palette: &ScrollPalette, ) -> View where Msg: Clone + 'static, H: FnOnce(f32) -> View, F: Fn(f32) -> Msg + Send + Sync + 'static, { let range = (header_max - header_min).max(0.0); let h = collapsed_height(offset, header_max, header_min); let frac = collapse_fraction(offset, header_max, header_min); // El cuerpo recién empieza a scrollear cuando el colapso terminó. let body_offset = (offset - range).max(0.0); let body_vp = (viewport_len - h).max(0.0); let on_scroll = Arc::new(on_scroll); let line_px = palette.line_px; // Header pinned (altura `h`), recortado, con rueda propia. let s_head = on_scroll.clone(); let header_box = View::new(Style { size: Size { width: percent(1.0_f32), height: length(h) }, ..Default::default() }) .clip(true) .on_scroll(move |_dx, dy| Some((s_head)(dy * line_px))) .children(vec![header(frac)]); // Cuerpo: reusa scroll_y con el viewport restante y el offset del cuerpo. let s_body = on_scroll; let body = scroll_y( body_offset, content_len, body_vp, content, move |d| (s_body)(d), palette, ); View::new(Style { flex_direction: llimphi_compositor::llimphi_layout::taffy::prelude::FlexDirection::Column, size: Size { width: percent(1.0_f32), height: length(viewport_len) }, ..Default::default() }) .clip(true) .children(vec![header_box, body]) } #[cfg(test)] mod tests { use super::*; #[test] fn scroll_take_consume_hasta_el_tope_y_devuelve_el_resto() { // offset 70 de max 100: bajando 50 consume 30 y sobran 20. assert_eq!(scroll_take(50.0, 70.0, 100.0), (30.0, 20.0)); // Subiendo 50 con offset 20: consume -20 y sobran -30. assert_eq!(scroll_take(-50.0, 20.0, 100.0), (-20.0, -30.0)); // Lejos de los topes: consume todo, resto 0. assert_eq!(scroll_take(10.0, 50.0, 100.0), (10.0, 0.0)); assert_eq!(scroll_take(-10.0, 50.0, 100.0), (-10.0, 0.0)); // Topado exacto: no consume nada. assert_eq!(scroll_take(10.0, 100.0, 100.0), (0.0, 10.0)); assert_eq!(scroll_take(-10.0, 0.0, 100.0), (0.0, -10.0)); // Overscrolleado (rubber-band, offset fuera de rango): no consume — // se comporta como topado, igual que el gate at_top/at_bottom viejo. assert_eq!(scroll_take(-5.0, -20.0, 100.0), (0.0, -5.0)); assert_eq!(scroll_take(5.0, 120.0, 100.0), (0.0, 5.0)); // Delta cero. assert_eq!(scroll_take(0.0, 50.0, 100.0), (0.0, 0.0)); } #[test] fn track_page_jump_sube_baja_o_ignora_sobre_el_thumb() { // thumb en y=100, alto 50 → ocupa [100,150]; viewport 400 → página 360. // Click arriba del thumb → sube una página. assert_eq!(track_page_jump(40.0, 100.0, 50.0, 400.0), -360.0); // Click debajo del thumb → baja una página. assert_eq!(track_page_jump(300.0, 100.0, 50.0, 400.0), 360.0); // Click sobre el thumb → sin salto (lo maneja el drag). assert_eq!(track_page_jump(120.0, 100.0, 50.0, 400.0), 0.0); } #[test] fn thumb_autohide_hold_fade_y_oculto() { // Pleno durante el hold. assert_eq!(thumb_autohide_alpha(0.0), 1.0); assert_eq!(thumb_autohide_alpha(THUMB_HOLD_SECS), 1.0); // A mitad del fade, alpha intermedio. let mid = thumb_autohide_alpha(THUMB_HOLD_SECS + THUMB_FADE_SECS * 0.5); assert!(mid > 0.0 && mid < 1.0, "alpha intermedio: {mid}"); // Pasado hold+fade, invisible y la app puede dejar de tickear. assert_eq!(thumb_autohide_alpha(THUMB_HOLD_SECS + THUMB_FADE_SECS + 0.1), 0.0); assert!(thumb_autohide_active(THUMB_HOLD_SECS + THUMB_FADE_SECS * 0.5)); assert!(!thumb_autohide_active(THUMB_HOLD_SECS + THUMB_FADE_SECS + 0.1)); } #[test] fn pull_progress_y_trigger() { assert_eq!(pull_progress(0.0, 64.0), 0.0); assert_eq!(pull_progress(32.0, 64.0), 0.5); assert_eq!(pull_progress(128.0, 64.0), 1.0); // satura assert_eq!(pull_progress(10.0, 0.0), 0.0); // umbral inválido assert!(!pull_triggered(63.9, 64.0)); assert!(pull_triggered(64.0, 64.0)); assert!(pull_triggered(100.0, 64.0)); } #[test] fn max_y_clamp() { assert_eq!(max_offset(1000.0, 300.0), 700.0); assert_eq!(max_offset(200.0, 300.0), 0.0); // entra entero assert_eq!(clamp_offset(-50.0, 1000.0, 300.0), 0.0); assert_eq!(clamp_offset(9999.0, 1000.0, 300.0), 700.0); assert_eq!(clamp_offset(400.0, 1000.0, 300.0), 400.0); } #[test] fn ensure_visible_arriba_abajo_y_sin_cambio() { let vp = 300.0; // Item por encima del offset → subir hasta su tope. assert_eq!(ensure_visible(500.0, vp, 100.0, 20.0), 100.0); // Item por debajo del fondo visible → bajar lo justo. assert_eq!(ensure_visible(0.0, vp, 400.0, 20.0), 120.0); // 400+20-300 // Item ya visible → sin cambios. assert_eq!(ensure_visible(50.0, vp, 100.0, 20.0), 50.0); // Nunca negativo. assert_eq!(ensure_visible(50.0, vp, -10.0, 20.0), 0.0); } #[test] fn approach_aterriza_exacto() { // Se acerca pero no salta. let a = approach(0.0, 100.0, 0.25); assert!(a > 0.0 && a < 100.0); // Diferencia < 0.5 px → aterriza exacto. assert_eq!(approach(99.8, 100.0, 0.25), 100.0); // factor 1.0 salta de una. assert_eq!(approach(0.0, 100.0, 1.0), 100.0); } #[test] fn fling_decae_y_se_detiene() { // Con fricción <1, la velocidad decae cada paso y el delta tiene el // signo de la velocidad. let (v1, d1) = fling_step(1000.0, 0.016, FLING_FRICTION); assert!(v1 < 1000.0 && v1 > 0.0); assert!(d1 > 0.0 && d1 < 1000.0 * 0.016 + 0.01); // < rectángulo v·dt // Tras muchos pasos de 16 ms, termina por debajo del umbral. let mut v = 1200.0_f32; let mut steps = 0; while !fling_settled(v) && steps < 100_000 { v = fling_step(v, 0.016, FLING_FRICTION).0; steps += 1; } assert!(fling_settled(v)); // Velocidad negativa → delta negativo (scrollea al revés). let (_, dneg) = fling_step(-500.0, 0.016, FLING_FRICTION); assert!(dneg < 0.0); // friction = 1.0 (sin fricción) → delta = v·dt exacto. let (v2, d2) = fling_step(300.0, 0.02, 1.0); assert!((v2 - 300.0).abs() < 1e-3); assert!((d2 - 6.0).abs() < 1e-3); } #[test] fn rubber_band_amortigua() { let dim = 600.0; // Siempre menor en magnitud que el overscroll crudo. assert!(rubber_band(100.0, dim) < 100.0); assert!(rubber_band(100.0, dim) > 0.0); // Conserva el signo. assert!(rubber_band(-80.0, dim) < 0.0); // Rendimiento decreciente: estirar 2× no duplica el desplazamiento. let a = rubber_band(100.0, dim); let b = rubber_band(200.0, dim); assert!(b > a && b < 2.0 * a); // Cerca de 0 es casi lineal (poca amortiguación todavía). assert!(rubber_band(0.0, dim).abs() < 1e-6); } #[test] fn sliver_colapso_y_max() { // Header 200→64, viewport 500, contenido 1200. let (max_h, min_h) = (200.0, 64.0); // Offset 0 → expandido, frac 0. assert_eq!(collapsed_height(0.0, max_h, min_h), 200.0); assert_eq!(collapse_fraction(0.0, max_h, min_h), 0.0); // A mitad del rango (68px de 136) → ~0.5 y altura ~132. let mid = (max_h - min_h) / 2.0; // 68 assert!((collapse_fraction(mid, max_h, min_h) - 0.5).abs() < 1e-3); assert!((collapsed_height(mid, max_h, min_h) - 132.0).abs() < 1e-3); // Pasado el rango → fijado al mínimo, frac 1. assert_eq!(collapsed_height(500.0, max_h, min_h), 64.0); assert_eq!(collapse_fraction(500.0, max_h, min_h), 1.0); // Max offset = rango (136) + scroll del cuerpo bajo el header mínimo. let body_vp = 500.0 - min_h; // 436 let expected = 136.0 + max_offset(1200.0, body_vp); assert!((sliver_max_offset(1200.0, 500.0, max_h, min_h) - expected).abs() < 1e-3); } #[test] fn sticky_pegado_y_empujado() { // Sección [100, 100+300], encabezado 40px de alto. let (top, sh, hh) = (100.0, 300.0, 40.0); // Antes de llegar al tope (offset 50 < 100): posición natural 50. assert_eq!(sticky_y(50.0, top, sh, hh), 50.0); // Dentro de la sección (offset 200 > top): pegado al tope (0). assert_eq!(sticky_y(200.0, top, sh, hh), 0.0); // Cerca del fondo de la sección: la próxima lo empuja hacia arriba (<0). // section_bottom - hh = (100+300-380) - 40 = -20. assert_eq!(sticky_y(380.0, top, sh, hh), -20.0); } #[test] fn thumb_proporcional_y_topes() { // Contenido entra entero → thumb cubre todo, sin travel. let (h, y, opp) = thumb_geometry(0.0, 200.0, 300.0); assert_eq!((h, y, opp), (300.0, 0.0, 0.0)); // Contenido 3× viewport → thumb ≈ 1/3 (clampeado a MIN_THUMB). let (h, y, _) = thumb_geometry(0.0, 900.0, 300.0); assert!((h - 100.0).abs() < 0.01); assert_eq!(y, 0.0); // En el máximo offset, el thumb toca el fondo del track. let max = max_offset(900.0, 300.0); let (h2, y2, _) = thumb_geometry(max, 900.0, 300.0); assert!((y2 + h2 - 300.0).abs() < 0.01); } }