tawasuyu

tawasuyu/llimphi

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

Rust 484Markdown 258TOML 131RON 38118 Definiciones

⎇ main widgets/scroll/examples/scroll_avanzado.rs Rust · 206 líneas · 6.9 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! Showcase de scroll avanzado (Tier 5): **app-bar colapsable** (sliver) +
2//! lista scrolleable + **inercia (fling)**. Un único `offset` en el Model
3//! maneja el colapso del header y el scroll del cuerpo; los botones "Fling"
4//! sueltan una velocidad que decae con [`fling_step`] vía un ticker periódico.
5//!
6//! Corre con:
7//! ```text
8//! cargo run -p llimphi-widget-scroll --example scroll_avanzado --release
9//! ```
10
11use std::time::Duration;
12
13use llimphi_theme::Theme;
14use llimphi_ui::llimphi_layout::taffy::{
15    prelude::{length, percent, FlexDirection, Size, Style},
16    AlignItems, JustifyContent, Rect,
17};
18use llimphi_ui::{App, Handle, View};
19use llimphi_widget_scroll::{
20    clamp_offset, fling_settled, fling_step, sliver_app_bar, sliver_max_offset, ScrollPalette,
21    FLING_FRICTION,
22};
23
24const HEADER_MAX: f32 = 200.0;
25const HEADER_MIN: f32 = 56.0;
26const VIEWPORT: f32 = 560.0;
27const ROW_H: f32 = 46.0;
28const N_ROWS: usize = 40;
29const CONTENT_LEN: f32 = N_ROWS as f32 * ROW_H;
30const DT: f32 = 1.0 / 60.0;
31
32#[derive(Clone)]
33enum Msg {
34    /// Delta de scroll en px (rueda / arrastre de barra) a sumar al offset.
35    ScrollBy(f32),
36    /// Soltar una inercia con esta velocidad inicial (px/s).
37    Fling(f32),
38    /// Tick del ticker: avanza la inercia si hay.
39    Tick,
40}
41
42struct Model {
43    offset: f32,
44    velocity: f32,
45    theme: Theme,
46}
47
48fn max_off() -> f32 {
49    sliver_max_offset(CONTENT_LEN, VIEWPORT, HEADER_MAX, HEADER_MIN)
50}
51
52struct Demo;
53
54impl App for Demo {
55    type Model = Model;
56    type Msg = Msg;
57
58    fn title() -> &'static str {
59        "llimphi · scroll avanzado (sliver + fling)"
60    }
61
62    fn initial_size() -> (u32, u32) {
63        (720, VIEWPORT as u32)
64    }
65
66    fn init(handle: &Handle<Self::Msg>) -> Self::Model {
67        // Ticker de inercia ~60 fps (mismo patrón que `approach`).
68        handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
69        Model { offset: 0.0, velocity: 0.0, theme: Theme::dark() }
70    }
71
72    fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
73        match msg {
74            Msg::ScrollBy(d) => {
75                model.velocity = 0.0; // un scroll manual corta la inercia
76                model.offset = clamp_offset(model.offset + d, max_off() + VIEWPORT, VIEWPORT);
77                // (clamp_offset usa content/viewport; aquí el "content" efectivo
78                // es max_off + viewport, así max_offset(...) == max_off.)
79            }
80            Msg::Fling(v) => model.velocity = v,
81            Msg::Tick => {
82                if model.velocity != 0.0 {
83                    let (v, delta) = fling_step(model.velocity, DT, FLING_FRICTION);
84                    model.offset =
85                        clamp_offset(model.offset + delta, max_off() + VIEWPORT, VIEWPORT);
86                    // Frenar en los topes o al asentarse.
87                    if fling_settled(v) || model.offset <= 0.0 || model.offset >= max_off() {
88                        model.velocity = 0.0;
89                    } else {
90                        model.velocity = v;
91                    }
92                }
93            }
94        }
95        model
96    }
97
98    fn view(model: &Self::Model) -> View<Self::Msg> {
99        let t = &model.theme;
100        let pal = ScrollPalette::from_theme(t);
101
102        // Lista (cuerpo del sliver): filas alternadas.
103        let rows: Vec<View<Msg>> = (0..N_ROWS)
104            .map(|i| {
105                let bg = if i % 2 == 0 { t.bg_panel } else { t.bg_panel_alt };
106                View::new(Style {
107                    size: Size { width: percent(1.0), height: length(ROW_H) },
108                    align_items: Some(AlignItems::CENTER),
109                    padding: Rect { left: length(20.0), right: length(20.0), top: length(0.0), bottom: length(0.0) },
110                    ..Default::default()
111                })
112                .fill(bg)
113                .text(format!("Fila {:02}", i + 1), 18.0, t.fg_text)
114            })
115            .collect();
116        let list = View::new(Style {
117            flex_direction: FlexDirection::Column,
118            size: Size { width: percent(1.0), height: length(CONTENT_LEN) },
119            ..Default::default()
120        })
121        .children(rows);
122
123        let theme = t.clone();
124        let sliver = sliver_app_bar(
125            model.offset,
126            HEADER_MAX,
127            HEADER_MIN,
128            move |frac| header(&theme, frac),
129            list,
130            CONTENT_LEN,
131            VIEWPORT,
132            Msg::ScrollBy,
133            &pal,
134        );
135
136        View::new(Style {
137            size: Size { width: percent(1.0), height: percent(1.0) },
138            ..Default::default()
139        })
140        .fill(t.bg_app)
141        .children(vec![sliver])
142    }
143}
144
145/// Header colapsable: el título encoge con `frac` y el subtítulo + botones de
146/// fling se desvanecen al colapsar (el `clip` del header los recorta).
147fn header(t: &Theme, frac: f32) -> View<Msg> {
148    let title_size = 34.0 - 14.0 * frac; // 34 → 20
149    // Fondo que se aclara al colapsar (de accent a panel).
150    let title_row = View::new(Style {
151        flex_direction: FlexDirection::Row,
152        size: Size { width: percent(1.0), height: length(HEADER_MIN) },
153        align_items: Some(AlignItems::CENTER),
154        justify_content: Some(JustifyContent::SPACE_BETWEEN),
155        padding: Rect { left: length(20.0), right: length(16.0), top: length(0.0), bottom: length(0.0) },
156        ..Default::default()
157    })
158    .children(vec![
159        View::new(Style { ..Default::default() })
160            .text("Scroll avanzado", title_size, t.fg_text),
161        fling_buttons(t),
162    ]);
163
164    let subtitle = View::new(Style {
165        size: Size { width: percent(1.0), height: length(28.0) },
166        align_items: Some(AlignItems::CENTER),
167        padding: Rect { left: length(20.0), right: length(20.0), top: length(0.0), bottom: length(0.0) },
168        ..Default::default()
169    })
170    .alpha(1.0 - frac) // se desvanece al colapsar
171    .text("Tier 5 · app-bar colapsable + inercia · rueda para scrollear", 15.0, t.fg_muted);
172
173    View::new(Style {
174        flex_direction: FlexDirection::Column,
175        size: Size { width: percent(1.0), height: length(HEADER_MAX) },
176        ..Default::default()
177    })
178    .fill(t.bg_panel_alt)
179    .children(vec![title_row, subtitle])
180}
181
182fn fling_buttons(t: &Theme) -> View<Msg> {
183    let btn = |label: &str, v: f32| {
184        View::new(Style {
185            size: Size { width: length(96.0), height: length(34.0) },
186            align_items: Some(AlignItems::CENTER),
187            justify_content: Some(JustifyContent::CENTER),
188            ..Default::default()
189        })
190        .fill(t.bg_button)
191        .hover_fill(t.bg_button_hover)
192        .radius(8.0)
193        .text(label.to_string(), 15.0, t.fg_text)
194        .on_click(Msg::Fling(v))
195    };
196    View::new(Style {
197        flex_direction: FlexDirection::Row,
198        gap: Size { width: length(8.0), height: length(0.0) },
199        ..Default::default()
200    })
201    .children(vec![btn("Fling ▲", -2600.0), btn("Fling ▼", 2600.0)])
202}
203
204fn main() {
205    llimphi_ui::run::<Demo>();
206}