From 97c09bc96a9c41ade9d40ab3c3613e35909a9f12 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 13 May 2026 02:36:41 +0000 Subject: [PATCH] feat(lapaloma): backend GPUI + LapalomaChartElement + app demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cadena end-to-end DataBuffer → LineSeries → Canvas → gpui::Window funcionando. cargo run -p lapaloma-demo abre una ventana con sin(x) sobre 1024 muestras y una sola paint_path por frame. - lapaloma-render: feature `gpui` opcional. WindowCanvas adapter traduce el trait Canvas a paint_quad/paint_path de gpui 0.2. Conversión RGB→HSL para integrar con el sistema de colores Hsla del resto del codebase yahweh. 3 tests de conversión. - lapaloma-cartesian: feature `gpui` (default). element::LapalomaChartElement con impl Element + IntoElement. Arma WindowCanvas en paint() y delega a LineSeries — un solo paint_path por chart. - crates/apps/lapaloma-demo registrado en workspace. Limitaciones conocidas v0.1: clip stack, triangle strips y draw_text no implementados (los necesitan phosphor / Sankey / axes; se agregan en sus fases). Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 13 ++ Cargo.toml | 1 + crates/apps/lapaloma-demo/Cargo.toml | 16 ++ crates/apps/lapaloma-demo/src/main.rs | 93 ++++++++++ .../widgets/lapaloma-cartesian/Cargo.toml | 6 +- .../widgets/lapaloma-cartesian/src/element.rs | 165 ++++++++++++++++++ .../widgets/lapaloma-cartesian/src/lib.rs | 7 +- .../widgets/lapaloma-render/Cargo.toml | 5 + .../lapaloma-render/src/gpui_backend.rs | 154 ++++++++++++++++ .../widgets/lapaloma-render/src/lib.rs | 6 + 10 files changed, 464 insertions(+), 2 deletions(-) create mode 100644 crates/apps/lapaloma-demo/Cargo.toml create mode 100644 crates/apps/lapaloma-demo/src/main.rs create mode 100644 crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs create mode 100644 crates/modules/ui_engine/widgets/lapaloma-render/src/gpui_backend.rs diff --git a/Cargo.lock b/Cargo.lock index b1adef1..90314b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5393,6 +5393,18 @@ dependencies = [ name = "lapaloma-core" version = "0.1.0" +[[package]] +name = "lapaloma-demo" +version = "0.1.0" +dependencies = [ + "gpui", + "lapaloma-cartesian", + "lapaloma-core", + "lapaloma-render", + "yahweh-launcher", + "yahweh-theme", +] + [[package]] name = "lapaloma-export" version = "0.1.0" @@ -5461,6 +5473,7 @@ dependencies = [ name = "lapaloma-render" version = "0.1.0" dependencies = [ + "gpui", "lapaloma-core", ] diff --git a/Cargo.toml b/Cargo.toml index 0e444ad..8b31161 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -136,6 +136,7 @@ members = [ "crates/apps/shipote-gateway", "crates/apps/shipote-shell", "crates/apps/gioser-web", + "crates/apps/lapaloma-demo", ] [workspace.package] diff --git a/crates/apps/lapaloma-demo/Cargo.toml b/crates/apps/lapaloma-demo/Cargo.toml new file mode 100644 index 0000000..abf040e --- /dev/null +++ b/crates/apps/lapaloma-demo/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "lapaloma-demo" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +authors = { workspace = true } +publish = { workspace = true } +description = "Lapaloma — demo app: una serie sin(x) sobre ChartViewport rendereada con LapalomaChartElement. Valida la cadena core → render → cartesian → gpui en vivo." + +[dependencies] +gpui = { workspace = true } +yahweh-launcher = { path = "../../modules/ui_engine/libs/launcher" } +yahweh-theme = { path = "../../modules/ui_engine/libs/theme" } +lapaloma-core = { path = "../../modules/ui_engine/libs/lapaloma-core" } +lapaloma-render = { path = "../../modules/ui_engine/widgets/lapaloma-render", features = ["gpui"] } +lapaloma-cartesian = { path = "../../modules/ui_engine/widgets/lapaloma-cartesian" } diff --git a/crates/apps/lapaloma-demo/src/main.rs b/crates/apps/lapaloma-demo/src/main.rs new file mode 100644 index 0000000..3c6ce14 --- /dev/null +++ b/crates/apps/lapaloma-demo/src/main.rs @@ -0,0 +1,93 @@ +//! `lapaloma-demo` — demo visual mínimo de Lapaloma sobre yahweh. +//! +//! Levanta una ventana de 900×560 con un único chart cartesiano +//! pre-llenado con `sin(x · 0.04)` sobre 1024 muestras. Sirve +//! como smoke test de la cadena completa: +//! +//! ```text +//! DataBuffer (lapaloma-core) +//! ↓ LTTB cuando densidad > 3× ancho del plot +//! CoordinateSystem (lapaloma-cartesian) +//! ↓ project_buffer dominio → pixel, zero-alloc +//! LineSeries (lapaloma-cartesian) +//! ↓ canvas.stroke_polyline (una sola draw call) +//! WindowCanvas (lapaloma-render::gpui_backend) +//! ↓ paint_path +//! gpui::Window +//! ``` +//! +//! Correr con `cargo run -p lapaloma-demo`. Requiere DISPLAY/WAYLAND. + +use gpui::{div, prelude::*, px, Context, IntoElement, Render, Window}; + +use lapaloma_cartesian::{lapaloma_chart, ChartViewport}; +use lapaloma_core::buffer::DataBuffer; +use lapaloma_render::{Color, StrokeStyle}; +use yahweh_launcher::launch_app; +use yahweh_theme::Theme; + +const N_SAMPLES: usize = 1024; +const FREQ: f32 = 0.04; + +fn main() { + launch_app("Lapaloma — sin(x) demo", (900., 560.), Demo::new); +} + +struct Demo { + data: DataBuffer, +} + +impl Demo { + fn new(_cx: &mut Context) -> Self { + // Buffer canónico interleaved: x va 0..N-1, y = sin(x · FREQ). + let mut data = DataBuffer::with_capacity(N_SAMPLES); + for i in 0..N_SAMPLES { + let x = i as f32; + let y = (x * FREQ).sin(); + data.push(x, y); + } + Self { data } + } +} + +impl Render for Demo { + fn render(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::global(cx).clone(); + + // Viewport: ve toda la X, Y con un poco de margen. + let viewport = ChartViewport::new(0.0, (N_SAMPLES - 1) as f64, -1.1, 1.1); + + // Estilo: stroke nórdico azul claro sobre fondo del theme. + let stroke = StrokeStyle::new(2.0, Color::from_hex(0x88c0d0)); + let plot_bg = Color::rgba(0.10, 0.12, 0.16, 1.0); + + // DataBuffer es `Clone`; el Element toma ownership del clone + // por frame. Para datasets enormes el siguiente paso es + // pasar a `Arc`; con 1k samples es trivial. + let chart = lapaloma_chart(self.data.clone(), viewport, stroke).background(plot_bg); + + div() + .size_full() + .bg(theme.bg_app.clone()) + .p(px(16.)) + .flex() + .flex_col() + .gap(px(12.)) + .child( + div() + .text_color(theme.fg_text) + .text_size(px(18.)) + .child("Lapaloma — demo cartesian"), + ) + .child( + div() + .text_color(theme.fg_muted) + .text_size(px(12.)) + .child(format!( + "{} muestras de sin(x · {}). LineSeries · LTTB-on-density · 1 draw call.", + N_SAMPLES, FREQ + )), + ) + .child(div().w_full().flex_grow().child(chart)) + } +} diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/Cargo.toml b/crates/modules/ui_engine/widgets/lapaloma-cartesian/Cargo.toml index 09892e9..e895f9c 100644 --- a/crates/modules/ui_engine/widgets/lapaloma-cartesian/Cargo.toml +++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/Cargo.toml @@ -10,4 +10,8 @@ description = "Lapaloma — gráficos cartesianos: LineSeries / BarSeries / Area [dependencies] lapaloma-core = { path = "../../libs/lapaloma-core" } lapaloma-render = { path = "../lapaloma-render" } -gpui = { workspace = true } +gpui = { workspace = true, optional = true } + +[features] +default = ["gpui"] +gpui = ["dep:gpui", "lapaloma-render/gpui"] diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs new file mode 100644 index 0000000..213735e --- /dev/null +++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs @@ -0,0 +1,165 @@ +//! `LapalomaChartElement` — el `Element` GPUI que envuelve el +//! pipeline cartesian. +//! +//! Owns un `DataBuffer` y un `ChartViewport`. En `paint()` arma el +//! `WindowCanvas` adapter de `lapaloma-render` y delega a una +//! [`LineSeries`]. El resultado: una sola `stroke_polyline` = +//! una sola `paint_path` de GPUI = un solo draw call. +//! +//! Sin event handlers todavía — pan/zoom interactivos van en una +//! fase posterior cuando enganchemos los gesture handlers de GPUI. + +use std::panic; + +use gpui::{ + App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId, + Pixels, Style, Window, +}; + +use lapaloma_core::buffer::DataBuffer; +use lapaloma_render::{Canvas, Color, Rect, StrokeStyle, WindowCanvas}; + +use crate::coord_system::CoordinateSystem; +use crate::series::{LineSeries, PaintCtx, RenderMode, Series}; +use crate::viewport::ChartViewport; + +/// Chart cartesiano de una sola serie. Para múltiples series va a +/// venir un `LapalomaChart` que componga varios `Series` boxed — +/// por ahora arrancamos con el caso mono-serie. +pub struct LapalomaChartElement { + pub data: DataBuffer, + pub viewport: ChartViewport, + pub stroke: StrokeStyle, + /// Color de fondo del plot. `None` = transparente, hereda + /// el container. + pub background: Option, + /// Padding interior del plot (deja espacio para futuros ejes). + pub padding: f32, + /// Scratch buffer reusable entre frames. Sin Arc/Mutex porque + /// el Element se mueve al árbol y no se comparte. + scratch: Vec, +} + +impl LapalomaChartElement { + pub fn new(data: DataBuffer, viewport: ChartViewport, stroke: StrokeStyle) -> Self { + Self { + data, + viewport, + stroke, + background: None, + padding: 8.0, + scratch: Vec::new(), + } + } + + pub fn background(mut self, color: Color) -> Self { + self.background = Some(color); + self + } + + pub fn padding(mut self, px: f32) -> Self { + self.padding = px; + self + } +} + +impl IntoElement for LapalomaChartElement { + type Element = Self; + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for LapalomaChartElement { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + // Layout default: ocupa lo que su parent le dé via + // size_full(). El usuario arma el sizing afuera con + // div().w_full().h(px(N)) o equivalente. + let mut style = Style::default(); + style.size.width = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0)); + style.size.height = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0)); + let id = window.request_layout(style, [], cx); + (id, ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + _window: &mut Window, + _cx: &mut App, + ) -> Self::PrepaintState { + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + window: &mut Window, + _cx: &mut App, + ) { + let ox: f32 = bounds.origin.x.into(); + let oy: f32 = bounds.origin.y.into(); + let w: f32 = bounds.size.width.into(); + let h: f32 = bounds.size.height.into(); + let plot = Rect::new( + ox + self.padding, + oy + self.padding, + (w - self.padding * 2.0).max(1.0), + (h - self.padding * 2.0).max(1.0), + ); + + let cs = CoordinateSystem::new(self.viewport, plot); + let mut canvas = WindowCanvas::new(window); + + if let Some(bg) = self.background { + canvas.fill_rect(Rect::new(ox, oy, w, h), bg); + } + + let series = LineSeries::new(&self.data, self.stroke); + self.scratch.clear(); + let mut ctx = PaintCtx { + cs, + mode: RenderMode::UiRich, + scratch: &mut self.scratch, + }; + series.paint(&mut ctx, &mut canvas); + } +} + +/// Helper builder-style para uso ergonómico desde `Render::render`. +/// +/// ```ignore +/// div().w_full().h(px(300.)).child( +/// lapaloma_chart(buf, viewport, stroke).background(rgb(0xff000000)) +/// ) +/// ``` +pub fn lapaloma_chart( + data: DataBuffer, + viewport: ChartViewport, + stroke: StrokeStyle, +) -> LapalomaChartElement { + LapalomaChartElement::new(data, viewport, stroke) +} diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs index c8ce08a..d461d12 100644 --- a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs +++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs @@ -27,11 +27,16 @@ pub mod viewport; pub mod coord_system; pub mod series; +#[cfg(feature = "gpui")] +pub mod element; + // Pendientes — siguen como placeholders hasta su fase. pub mod axis {} pub mod picture_cache {} -pub mod element {} pub use viewport::ChartViewport; pub use coord_system::CoordinateSystem; pub use series::{LineSeries, PaintCtx, RenderMode, Series}; + +#[cfg(feature = "gpui")] +pub use element::{lapaloma_chart, LapalomaChartElement}; diff --git a/crates/modules/ui_engine/widgets/lapaloma-render/Cargo.toml b/crates/modules/ui_engine/widgets/lapaloma-render/Cargo.toml index 4eee5e0..08d4dda 100644 --- a/crates/modules/ui_engine/widgets/lapaloma-render/Cargo.toml +++ b/crates/modules/ui_engine/widgets/lapaloma-render/Cargo.toml @@ -9,3 +9,8 @@ description = "Lapaloma — abstracción de painter: trait Canvas + RenderPlan + [dependencies] lapaloma-core = { path = "../../libs/lapaloma-core" } +gpui = { workspace = true, optional = true } + +[features] +default = [] +gpui = ["dep:gpui"] diff --git a/crates/modules/ui_engine/widgets/lapaloma-render/src/gpui_backend.rs b/crates/modules/ui_engine/widgets/lapaloma-render/src/gpui_backend.rs new file mode 100644 index 0000000..7683a4b --- /dev/null +++ b/crates/modules/ui_engine/widgets/lapaloma-render/src/gpui_backend.rs @@ -0,0 +1,154 @@ +//! Backend CPU del trait [`crate::Canvas`] sobre `gpui::Window`. +//! +//! Bajo el feature `gpui`. Traduce los primitivos de Lapaloma a las +//! llamadas nativas de GPUI 0.2 (`paint_quad`, `paint_path`). No +//! introduce dependencia transitiva a gpui en los crates de +//! visualización — éstos siguen hablando contra el trait abstracto; +//! sólo el `Element` GPUI de cada widget importa este módulo. +//! +//! Limitaciones de la implementación CPU: +//! - `push_clip` / `pop_clip` quedan como no-op por ahora — GPUI +//! maneja content mask via builders de alto nivel; el chart se +//! apoya en el bounds del Element para no pintar fuera. +//! - `fill_triangle_strip` no implementado (lo necesitan phosphor +//! y Sankey, que aún no están). +//! - `draw_text` no implementado (axis labels lo necesitan; va con +//! `WindowTextSystem` en una fase próxima). + +use crate::{Canvas, Color, Point, Rect, StrokeStyle}; +use gpui::{fill, hsla, point as gpui_point, px, size as gpui_size, Bounds, Hsla, PathBuilder, Window}; + +/// Adapter que pinta sobre un `&mut Window` de GPUI. +/// +/// Vida útil del borrow del window iguala la de la pintura. Construir +/// uno nuevo en cada `paint()` del Element. +pub struct WindowCanvas<'a> { + window: &'a mut Window, +} + +impl<'a> WindowCanvas<'a> { + pub fn new(window: &'a mut Window) -> Self { + Self { window } + } +} + +/// Conversión RGB(a) → HSL(a). GPUI consume `Hsla` para casi todo +/// el path. Linear, sin gamma — coincide con la convención del +/// resto del codebase yahweh. +pub(crate) fn color_to_hsla(c: Color) -> Hsla { + let (r, g, b, a) = (c.r, c.g, c.b, c.a); + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let l = (max + min) * 0.5; + let delta = max - min; + if delta.abs() < 1e-6 { + return hsla(0.0, 0.0, l, a); + } + let s = if l < 0.5 { delta / (max + min) } else { delta / (2.0 - max - min) }; + let h = if max == r { + ((g - b) / delta).rem_euclid(6.0) + } else if max == g { + (b - r) / delta + 2.0 + } else { + (r - g) / delta + 4.0 + }; + hsla(h / 6.0, s, l, a) +} + +fn to_bounds(r: Rect) -> Bounds { + Bounds { + origin: gpui_point(px(r.x), px(r.y)), + size: gpui_size(px(r.w), px(r.h)), + } +} + +impl<'a> Canvas for WindowCanvas<'a> { + fn push_clip(&mut self, _rect: Rect) { + // Sin clip explícito por ahora. El Element pinta dentro + // de sus bounds y los painters de lapaloma respetan el + // plot_rect en sus proyecciones. + } + fn pop_clip(&mut self) {} + + fn fill_rect(&mut self, rect: Rect, color: Color) { + let hsla = color_to_hsla(color); + self.window.paint_quad(fill(to_bounds(rect), hsla)); + } + + fn stroke_rect(&mut self, rect: Rect, stroke: StrokeStyle) { + // 4 line segments con PathBuilder en stroke mode. + let mut pb = PathBuilder::stroke(px(stroke.width)); + pb.move_to(gpui_point(px(rect.x), px(rect.y))); + pb.line_to(gpui_point(px(rect.right()), px(rect.y))); + pb.line_to(gpui_point(px(rect.right()), px(rect.bottom()))); + pb.line_to(gpui_point(px(rect.x), px(rect.bottom()))); + pb.close(); + if let Ok(path) = pb.build() { + self.window.paint_path(path, color_to_hsla(stroke.color)); + } + } + + fn stroke_line(&mut self, a: Point, b: Point, stroke: StrokeStyle) { + let mut pb = PathBuilder::stroke(px(stroke.width)); + pb.move_to(gpui_point(px(a.x), px(a.y))); + pb.line_to(gpui_point(px(b.x), px(b.y))); + if let Ok(path) = pb.build() { + self.window.paint_path(path, color_to_hsla(stroke.color)); + } + } + + fn stroke_polyline(&mut self, coords: &[f32], stroke: StrokeStyle) { + if coords.len() < 4 { + return; // <2 puntos → no hay segmento + } + let mut pb = PathBuilder::stroke(px(stroke.width)); + pb.move_to(gpui_point(px(coords[0]), px(coords[1]))); + let mut i = 2; + while i + 1 < coords.len() { + pb.line_to(gpui_point(px(coords[i]), px(coords[i + 1]))); + i += 2; + } + if let Ok(path) = pb.build() { + self.window.paint_path(path, color_to_hsla(stroke.color)); + } + } + + fn fill_triangle_strip(&mut self, _coords: &[f32], _colors: &[Color]) { + // TODO: cuando phosphor / Sankey lo necesiten. GPUI no + // tiene API directa para triangle strips con per-vertex + // color — habrá que descomponer en quads o subir un + // vertex buffer wgpu. + } + + fn draw_text(&mut self, _p: Point, _text: &str, _color: Color, _size_px: f32) { + // TODO: integrar con WindowTextSystem para axis labels. + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rgb_a_hsla_grises() { + // (0.5, 0.5, 0.5) → h=0, s=0, l=0.5 + let h = color_to_hsla(Color::rgb(0.5, 0.5, 0.5)); + assert!((h.s - 0.0).abs() < 1e-6); + assert!((h.l - 0.5).abs() < 1e-6); + } + + #[test] + fn rgb_a_hsla_rojo_puro() { + let h = color_to_hsla(Color::rgb(1.0, 0.0, 0.0)); + // Rojo: h=0, s=1, l=0.5 + assert!((h.h - 0.0).abs() < 1e-6); + assert!((h.s - 1.0).abs() < 1e-6); + assert!((h.l - 0.5).abs() < 1e-6); + } + + #[test] + fn rgb_a_hsla_alpha_pasa_directo() { + let h = color_to_hsla(Color::rgba(0.0, 0.0, 1.0, 0.3)); + assert!((h.a - 0.3).abs() < 1e-6); + } +} diff --git a/crates/modules/ui_engine/widgets/lapaloma-render/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-render/src/lib.rs index 44f05ae..f12f43a 100644 --- a/crates/modules/ui_engine/widgets/lapaloma-render/src/lib.rs +++ b/crates/modules/ui_engine/widgets/lapaloma-render/src/lib.rs @@ -24,7 +24,13 @@ pub mod geom; pub mod canvas; pub mod plan; +#[cfg(feature = "gpui")] +pub mod gpui_backend; + pub use color::Color; pub use geom::{Point, Rect}; pub use canvas::{Canvas, StrokeStyle}; pub use plan::{RenderCmd, RenderPlan}; + +#[cfg(feature = "gpui")] +pub use gpui_backend::WindowCanvas;