gioser

tawasuyu/pineal

Backend-agnostic GPU visualization — a catalog of painters over one Canvas trait, in Rust.

⎇ 514f17ba57e75d7e5db3a54d703d47377ca10fe6 00_unanchay/pineal/demos/pineal-heatmap-demo/src/main.rs Rust · 275 siq'ikuna · 8.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat: pineal standalone — catálogo de painters GPU sobre Llimphi (front-door liviano) 514f17ba5 · Sergio ·

1//! `pineal-heatmap-demo` — campo 2D con onda viajera.
2//!
3//! Matriz 48×32 que se reescribe cada 33 ms (≈ 30 Hz). El valor de
4//! cada celda combina dos sinusoides con desplazamiento de fase
5//! ligado al tick: `sin(x·0.25 - t·0.1) + cos(y·0.30 + t·0.07)`.
6//! El ramp Viridis mapea `[min, max]` de la matriz a color.
7//!
8//! Cableado de UI: barra de menú principal (Archivo / Ver / Ayuda) +
9//! menú contextual sobre el plot (right-click). Como es un canvas sin
10//! texto editable no hay menú Editar ni clipboard.
11
12use std::sync::{Arc, Mutex};
13use std::time::Duration;
14
15use llimphi_theme::Theme;
16use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
17use llimphi_ui::llimphi_layout::taffy::Rect as TaffyRect;
18use llimphi_ui::llimphi_text::Alignment;
19use llimphi_ui::{App, Handle, View};
20use llimphi_widget_context_menu::{
21    context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
22};
23use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
24
25use app_bus::{AppMenu, Menu, MenuItem};
26
27use pineal_heatmap::{paint, HeatmapMatrix, Ramp};
28use pineal_render::{Canvas as _, Color, Rect, SceneCanvas};
29
30const W: usize = 48;
31const H: usize = 32;
32const TICK_PERIOD: Duration = Duration::from_millis(33);
33
34#[derive(Clone)]
35enum Msg {
36    Tick,
37    /// Reinicia el campo al tick 0 (la "vista" del heatmap).
38    Reset,
39    /// Barra de menú principal: abrir/cerrar un menú raíz (`None` cerrar).
40    MenuOpen(Option<usize>),
41    /// Comando elegido en el menú principal — se traduce al `Msg` real.
42    MenuCommand(String),
43    /// Cierra cualquier menú abierto (click-fuera / Esc).
44    CloseMenus,
45    /// Cicla el tema claro/oscuro.
46    CycleTheme,
47    /// Right-click en la raíz → abre el menú contextual en `(x, y)`.
48    ContextMenuOpen(f32, f32),
49}
50
51struct Model {
52    matrix: Arc<Mutex<HeatmapMatrix>>,
53    t: u64,
54    theme: Theme,
55    menu_open: Option<usize>,
56    context_menu: Option<(f32, f32)>,
57}
58
59struct HeatmapDemo;
60
61impl App for HeatmapDemo {
62    type Model = Model;
63    type Msg = Msg;
64
65    fn title() -> &'static str {
66        "Lapaloma — heatmap 48×32 (Viridis · onda viajera)"
67    }
68    fn initial_size() -> (u32, u32) {
69        (960, 600)
70    }
71
72    fn init(handle: &Handle<Msg>) -> Model {
73        handle.spawn_periodic(TICK_PERIOD, || Msg::Tick);
74        let mut m = HeatmapMatrix::new(W, H);
75        fill(&mut m, 0);
76        Model {
77            matrix: Arc::new(Mutex::new(m)),
78            t: 0,
79            theme: Theme::dark(),
80            menu_open: None,
81            context_menu: None,
82        }
83    }
84
85    fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
86        match msg {
87            Msg::Tick => {
88                model.t = model.t.wrapping_add(1);
89                if let Ok(mut m) = model.matrix.lock() {
90                    fill(&mut m, model.t);
91                }
92            }
93            Msg::Reset => {
94                model.t = 0;
95                if let Ok(mut m) = model.matrix.lock() {
96                    fill(&mut m, 0);
97                }
98            }
99            Msg::MenuOpen(which) => {
100                model.menu_open = which;
101                model.context_menu = None;
102            }
103            Msg::MenuCommand(cmd) => {
104                model.menu_open = None;
105                return handle_menu_command(model, &cmd, handle);
106            }
107            Msg::CloseMenus => {
108                model.menu_open = None;
109                model.context_menu = None;
110            }
111            Msg::CycleTheme => {
112                model.theme = Theme::next_after(model.theme.name);
113            }
114            Msg::ContextMenuOpen(x, y) => {
115                model.menu_open = None;
116                model.context_menu = Some((x, y));
117            }
118        }
119        model
120    }
121
122    fn view(model: &Model) -> View<Msg> {
123        let theme = &model.theme;
124        let plot_bg = Color::rgba(0.06, 0.07, 0.10, 1.0);
125        let matrix = model.matrix.clone();
126
127        let menu = app_menu();
128        let menubar = menubar_view(&menubar_spec(&menu, model));
129
130        let header = View::new(Style {
131            size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
132            ..Default::default()
133        })
134        .text_aligned(
135            "Lapaloma — heatmap".to_string(),
136            18.0,
137            theme.fg_text,
138            Alignment::Start,
139        );
140
141        let stats = format!("matriz {}×{} · tick = {} · ramp = Viridis", W, H, model.t);
142        let legend = View::new(Style {
143            size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
144            ..Default::default()
145        })
146        .text_aligned(stats, 11.0, theme.fg_muted, Alignment::Start);
147
148        let panel = View::new(Style {
149            size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
150            flex_grow: 1.0,
151            ..Default::default()
152        })
153        .clip(true)
154        .paint_with(move |scene, ts, rect| {
155            let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
156            let mut canvas = SceneCanvas::new(scene, ts);
157            canvas.fill_rect(outer, plot_bg);
158            if let Ok(m) = matrix.lock() {
159                paint(&m, Ramp::Viridis, outer, &mut canvas);
160            }
161        });
162
163        let body = View::new(Style {
164            flex_direction: FlexDirection::Column,
165            size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
166            flex_grow: 1.0,
167            padding: TaffyRect {
168                left: length(16.0_f32),
169                right: length(16.0_f32),
170                top: length(16.0_f32),
171                bottom: length(16.0_f32),
172            },
173            gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
174            ..Default::default()
175        })
176        .fill(theme.bg_app)
177        .children(vec![header, legend, panel]);
178
179        View::new(Style {
180            flex_direction: FlexDirection::Column,
181            size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
182            ..Default::default()
183        })
184        .fill(theme.bg_app)
185        .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
186        .children(vec![menubar, body])
187    }
188
189    fn view_overlay(model: &Model) -> Option<View<Msg>> {
190        if let Some((x, y)) = model.context_menu {
191            let items = vec![
192                ContextMenuItem::action("Reiniciar vista"),
193                ContextMenuItem::action("Cambiar tema"),
194            ];
195            let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> =
196                Arc::new(|i: usize| match i {
197                    0 => Msg::Reset,
198                    _ => Msg::CycleTheme,
199                });
200            return Some(context_menu_view(ContextMenuSpec {
201                anchor: (x, y),
202                viewport: viewport_of(model),
203                header: Some("Heatmap".to_string()),
204                items,
205                active: usize::MAX,
206                on_pick,
207                on_dismiss: Msg::CloseMenus,
208                palette: ContextMenuPalette::from_theme(&model.theme),
209            }));
210        }
211        let menu = app_menu();
212        menubar_overlay(&menubar_spec(&menu, model))
213    }
214}
215
216fn viewport_of(_model: &Model) -> (f32, f32) {
217    let (w, h) = HeatmapDemo::initial_size();
218    (w as f32, h as f32)
219}
220
221fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
222    MenuBarSpec {
223        menu,
224        open: model.menu_open,
225        theme: &model.theme,
226        viewport: viewport_of(model),
227        height: MENU_H,
228        on_open: Arc::new(Msg::MenuOpen),
229        on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
230    }
231}
232
233fn app_menu() -> AppMenu {
234    AppMenu::new()
235        .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q")))
236        .menu(
237            Menu::new("Ver")
238                .item(MenuItem::new("Reiniciar vista", "view.reset"))
239                .item(MenuItem::new("Cambiar tema", "view.theme").separated()),
240        )
241        .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
242}
243
244fn handle_menu_command(model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
245    match cmd {
246        "file.quit" => std::process::exit(0),
247        "view.reset" => {
248            handle.dispatch(Msg::Reset);
249            model
250        }
251        "view.theme" => {
252            handle.dispatch(Msg::CycleTheme);
253            model
254        }
255        // "help.about" y desconocidos: no-op (sin diálogo todavía).
256        _ => model,
257    }
258}
259
260fn fill(m: &mut HeatmapMatrix, t: u64) {
261    let phase = t as f32 * 0.1;
262    let phase2 = t as f32 * 0.07;
263    let mut data = Vec::with_capacity(W * H);
264    for y in 0..H {
265        for x in 0..W {
266            let v = (x as f32 * 0.25 - phase).sin() + (y as f32 * 0.30 + phase2).cos();
267            data.push(v);
268        }
269    }
270    m.replace_data(data);
271}
272
273fn main() {
274    llimphi_ui::run::<HeatmapDemo>();
275}