refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel

Re-sincroniza las fuentes desde el monorepo (estaba en vello 0.5/wgpu 24 y con la
estructura vieja de eventloop) y suma el 3D:

- bump del workspace a vello 0.7 / wgpu 27 / parley 0.6, + accesskit 0.24 /
  accesskit_winit 0.33 / vello_hybrid 0.0.9.
- nuevos crates: llimphi-3d (voxels ray-march + mallas en un depth compartido,
  montable dentro de un View 2D vía set_viewport+scissor) y llimphi-voxel
  (world-gen, personajes, director de escenas) + shared/foreign-vox (puente .vox).
- README: sección "Not just 2D — a 3D voxel engine" + GIF (docs/llimphi_voxel.gif).
- excluido modules/allichay (arrastra deps fuera del alcance del front-door).
- cargo check --workspace: verde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-06-18 14:40:00 +00:00
parent e74800d9da
commit ccab39f140
202 changed files with 44034 additions and 1811 deletions
+4
View File
@@ -10,3 +10,7 @@ description = "llimphi-widget-scroll — área de scroll vertical reutilizable:
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
[[example]]
name = "scroll_avanzado"
path = "examples/scroll_avanzado.rs"
+206
View File
@@ -0,0 +1,206 @@
//! Showcase de scroll avanzado (Tier 5): **app-bar colapsable** (sliver) +
//! lista scrolleable + **inercia (fling)**. Un único `offset` en el Model
//! maneja el colapso del header y el scroll del cuerpo; los botones "Fling"
//! sueltan una velocidad que decae con [`fling_step`] vía un ticker periódico.
//!
//! Corré con:
//! ```text
//! cargo run -p llimphi-widget-scroll --example scroll_avanzado --release
//! ```
use std::time::Duration;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::{App, Handle, View};
use llimphi_widget_scroll::{
clamp_offset, fling_settled, fling_step, sliver_app_bar, sliver_max_offset, ScrollPalette,
FLING_FRICTION,
};
const HEADER_MAX: f32 = 200.0;
const HEADER_MIN: f32 = 56.0;
const VIEWPORT: f32 = 560.0;
const ROW_H: f32 = 46.0;
const N_ROWS: usize = 40;
const CONTENT_LEN: f32 = N_ROWS as f32 * ROW_H;
const DT: f32 = 1.0 / 60.0;
#[derive(Clone)]
enum Msg {
/// Delta de scroll en px (rueda / arrastre de barra) a sumar al offset.
ScrollBy(f32),
/// Soltar una inercia con esta velocidad inicial (px/s).
Fling(f32),
/// Tick del ticker: avanza la inercia si hay.
Tick,
}
struct Model {
offset: f32,
velocity: f32,
theme: Theme,
}
fn max_off() -> f32 {
sliver_max_offset(CONTENT_LEN, VIEWPORT, HEADER_MAX, HEADER_MIN)
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · scroll avanzado (sliver + fling)"
}
fn initial_size() -> (u32, u32) {
(720, VIEWPORT as u32)
}
fn init(handle: &Handle<Self::Msg>) -> Self::Model {
// Ticker de inercia ~60 fps (mismo patrón que `approach`).
handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
Model { offset: 0.0, velocity: 0.0, theme: Theme::dark() }
}
fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
match msg {
Msg::ScrollBy(d) => {
model.velocity = 0.0; // un scroll manual corta la inercia
model.offset = clamp_offset(model.offset + d, max_off() + VIEWPORT, VIEWPORT);
// (clamp_offset usa content/viewport; acá el "content" efectivo
// es max_off + viewport, así max_offset(...) == max_off.)
}
Msg::Fling(v) => model.velocity = v,
Msg::Tick => {
if model.velocity != 0.0 {
let (v, delta) = fling_step(model.velocity, DT, FLING_FRICTION);
model.offset =
clamp_offset(model.offset + delta, max_off() + VIEWPORT, VIEWPORT);
// Frenar en los topes o al asentarse.
if fling_settled(v) || model.offset <= 0.0 || model.offset >= max_off() {
model.velocity = 0.0;
} else {
model.velocity = v;
}
}
}
}
model
}
fn view(model: &Self::Model) -> View<Self::Msg> {
let t = &model.theme;
let pal = ScrollPalette::from_theme(t);
// Lista (cuerpo del sliver): filas alternadas.
let rows: Vec<View<Msg>> = (0..N_ROWS)
.map(|i| {
let bg = if i % 2 == 0 { t.bg_panel } else { t.bg_panel_alt };
View::new(Style {
size: Size { width: percent(1.0), height: length(ROW_H) },
align_items: Some(AlignItems::Center),
padding: Rect { left: length(20.0), right: length(20.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.fill(bg)
.text(format!("Fila {:02}", i + 1), 18.0, t.fg_text)
})
.collect();
let list = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0), height: length(CONTENT_LEN) },
..Default::default()
})
.children(rows);
let theme = t.clone();
let sliver = sliver_app_bar(
model.offset,
HEADER_MAX,
HEADER_MIN,
move |frac| header(&theme, frac),
list,
CONTENT_LEN,
VIEWPORT,
Msg::ScrollBy,
&pal,
);
View::new(Style {
size: Size { width: percent(1.0), height: percent(1.0) },
..Default::default()
})
.fill(t.bg_app)
.children(vec![sliver])
}
}
/// Header colapsable: el título encoge con `frac` y el subtítulo + botones de
/// fling se desvanecen al colapsar (el `clip` del header los recorta).
fn header(t: &Theme, frac: f32) -> View<Msg> {
let title_size = 34.0 - 14.0 * frac; // 34 → 20
// Fondo que se aclara al colapsar (de accent a panel).
let title_row = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0), height: length(HEADER_MIN) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::SpaceBetween),
padding: Rect { left: length(20.0), right: length(16.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.children(vec![
View::new(Style { ..Default::default() })
.text("Scroll avanzado", title_size, t.fg_text),
fling_buttons(t),
]);
let subtitle = View::new(Style {
size: Size { width: percent(1.0), height: length(28.0) },
align_items: Some(AlignItems::Center),
padding: Rect { left: length(20.0), right: length(20.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.alpha(1.0 - frac) // se desvanece al colapsar
.text("Tier 5 · app-bar colapsable + inercia · rueda para scrollear", 15.0, t.fg_muted);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0), height: length(HEADER_MAX) },
..Default::default()
})
.fill(t.bg_panel_alt)
.children(vec![title_row, subtitle])
}
fn fling_buttons(t: &Theme) -> View<Msg> {
let btn = |label: &str, v: f32| {
View::new(Style {
size: Size { width: length(96.0), height: length(34.0) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(t.bg_button)
.hover_fill(t.bg_button_hover)
.radius(8.0)
.text(label.to_string(), 15.0, t.fg_text)
.on_click(Msg::Fling(v))
};
View::new(Style {
flex_direction: FlexDirection::Row,
gap: Size { width: length(8.0), height: length(0.0) },
..Default::default()
})
.children(vec![btn("Fling ▲", -2600.0), btn("Fling ▼", 2600.0)])
}
fn main() {
llimphi_ui::run::<Demo>();
}
+566 -2
View File
@@ -65,6 +65,12 @@ 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 {
@@ -77,6 +83,12 @@ pub struct ScrollPalette {
/// 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 {
@@ -93,8 +105,17 @@ impl ScrollPalette {
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
@@ -144,6 +165,162 @@ pub fn approach(current: f32, target: f32, factor: f32) -> f32 {
}
}
/// 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 = f.powf(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) / f.ln()
};
(new_v, delta)
}
/// ¿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): trackeá `last_scroll: Instant` en el
/// Model, reseteándolo en cada `on_scroll`, y antes de llamar a
/// `scroll_y`/`scroll_xy` hacé `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`.
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
@@ -224,7 +401,7 @@ where
},
..Default::default()
})
.fill(palette.thumb)
.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 {
@@ -257,8 +434,16 @@ where
// 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);
let at_top = offset <= 0.0;
let at_bottom = offset >= max_off;
View::new(Style {
position: Position::Relative,
size: Size {
@@ -268,14 +453,320 @@ where
..Default::default()
})
.clip(true)
.on_scroll(move |_dx, dy| Some((on_wheel)(dy * line_px)))
.on_scroll(move |_dx, dy| {
let delta = dy * line_px;
if (delta < 0.0 && at_top) || (delta > 0.0 && at_bottom) {
return None;
}
Some((on_wheel)(delta))
})
.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, pasá `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<Msg, F>(
offset: (f32, f32),
content_size: (f32, f32),
viewport_size: (f32, f32),
content: View<Msg>,
on_scroll: F,
palette: &ScrollPalette,
) -> View<Msg>
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), 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), right: length(0.0), bottom: length(0.0), 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), 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), right: length(0.0), bottom: length(0.0), 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);
let at_left = ox <= 0.0;
let at_right = ox >= max_ox;
let at_top = oy <= 0.0;
let at_bottom = oy >= max_oy;
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(move |dx, dy| {
let ddx = dx * line_px;
let ddy = dy * line_px;
let x_blocked = (ddx < 0.0 && at_left)
|| (ddx > 0.0 && at_right)
|| ddx == 0.0;
let y_blocked = (ddy < 0.0 && at_top)
|| (ddy > 0.0 && at_bottom)
|| ddy == 0.0;
if x_blocked && y_blocked {
return None;
}
Some((on_wheel)(ddx, ddy))
})
.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.
pub fn pull_indicator_view<Msg: Clone + 'static>(
progress: f32,
refreshing: bool,
size_px: f32,
palette: &ScrollPalette,
) -> View<Msg> {
let p = progress.clamp(0.0, 1.0);
let arc_color = palette.thumb;
let track_color = palette.track;
let started = std::time::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_ui::llimphi_raster::kurbo::{Affine, Arc, Point, Shape, Stroke, Vec2};
use std::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 = -std::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<Msg, H, F>(
offset: f32,
header_max: f32,
header_min: f32,
header: H,
content: View<Msg>,
content_len: f32,
viewport_len: f32,
on_scroll: F,
palette: &ScrollPalette,
) -> View<Msg>
where
Msg: Clone + 'static,
H: FnOnce(f32) -> View<Msg>,
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), 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_ui::llimphi_layout::taffy::prelude::FlexDirection::Column,
size: Size { width: percent(1.0), height: length(viewport_len) },
..Default::default()
})
.clip(true)
.children(vec![header_box, body])
}
#[cfg(test)]
mod tests {
use super::*;
#[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);
@@ -309,6 +800,79 @@ mod tests {
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.