diff --git a/Cargo.lock b/Cargo.lock index 90314b3..6960d55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5486,6 +5486,18 @@ dependencies = [ "lapaloma-render", ] +[[package]] +name = "lapaloma-stream-demo" +version = "0.1.0" +dependencies = [ + "gpui", + "lapaloma-core", + "lapaloma-render", + "lapaloma-stream", + "yahweh-launcher", + "yahweh-theme", +] + [[package]] name = "lapaloma-treemap" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8b31161..7cacdc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -137,6 +137,7 @@ members = [ "crates/apps/shipote-shell", "crates/apps/gioser-web", "crates/apps/lapaloma-demo", + "crates/apps/lapaloma-stream-demo", ] [workspace.package] diff --git a/crates/apps/lapaloma-stream-demo/Cargo.toml b/crates/apps/lapaloma-stream-demo/Cargo.toml new file mode 100644 index 0000000..4c52714 --- /dev/null +++ b/crates/apps/lapaloma-stream-demo/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "lapaloma-stream-demo" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +authors = { workspace = true } +publish = { workspace = true } +description = "Lapaloma — demo de streaming: RingBuffer + timer 60 Hz + sweep render. Showcase del zero-alloc en hot path." + +[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-stream = { path = "../../modules/ui_engine/widgets/lapaloma-stream" } diff --git a/crates/apps/lapaloma-stream-demo/src/main.rs b/crates/apps/lapaloma-stream-demo/src/main.rs new file mode 100644 index 0000000..10266cd --- /dev/null +++ b/crates/apps/lapaloma-stream-demo/src/main.rs @@ -0,0 +1,125 @@ +//! `lapaloma-stream-demo` — osciloscopio sintético. +//! +//! Ventana con un `LapalomaStreamElement` montado sobre un +//! `RingBuffer` de 512 slots. Un timer en el background executor +//! empuja un sample cada **16 ms** (≈ 60 Hz) y dispara +//! `cx.notify()`. El sample es la suma de dos sinusoides desfasadas +//! más un poquito de ruido determinístico. +//! +//! El efecto visual: la traza barre la ventana como en un +//! osciloscopio CRT — split-at-head deja un "cursor" donde +//! arranca la traza fresca, la traza vieja se mantiene a la +//! derecha hasta que el cursor la sobrescriba. +//! +//! Showcase del **P2 zero-alloc en hot path**: el `push(v)` del +//! RingBuffer son 2 escrituras + 2 increments. Cero allocations +//! por frame, ningún `Vec` se reasigna. + +use std::time::Duration; + +use gpui::{div, prelude::*, px, Context, IntoElement, Render, Window}; + +use lapaloma_core::ring::RingBuffer; +use lapaloma_render::{Color, StrokeStyle}; +use lapaloma_stream::lapaloma_stream; +use yahweh_launcher::launch_app; +use yahweh_theme::Theme; + +const RING_CAPACITY: usize = 512; +const SAMPLE_PERIOD: Duration = Duration::from_millis(16); + +fn main() { + launch_app( + "Lapaloma — stream (osciloscopio sintético 60 Hz)", + (900., 480.), + StreamDemo::new, + ); +} + +struct StreamDemo { + buffer: RingBuffer, + /// Tick count global. Sirve de fase para la señal sintética y + /// se muestra en el header para verificar que el timer corre. + t: u64, +} + +impl StreamDemo { + fn new(cx: &mut Context) -> Self { + cx.spawn(async move |this, cx| { + let timer = cx.background_executor().clone(); + loop { + timer.timer(SAMPLE_PERIOD).await; + let r = this.update(cx, |me, cx| { + me.tick(); + cx.notify(); + }); + if r.is_err() { + break; + } + } + }) + .detach(); + + Self { + buffer: RingBuffer::new(RING_CAPACITY), + t: 0, + } + } + + fn tick(&mut self) { + let v = synthesize(self.t); + self.buffer.push(v); + self.t = self.t.wrapping_add(1); + } +} + +/// Señal sintética: suma de dos sinusoides + jitter determinístico. +/// El rango efectivo queda en `[-1, 1]` aproximadamente. +fn synthesize(t: u64) -> f32 { + let phase = t as f32; + let signal = (phase * 0.07).sin() * 0.75 + (phase * 0.19).sin() * 0.22; + let jitter = ((phase * 37.0).sin() * 1000.0).fract() * 0.04; + signal + jitter +} + +impl Render for StreamDemo { + fn render(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::global(cx).clone(); + + let plot_bg = Color::rgba(0.08, 0.10, 0.13, 1.0); + let stroke = StrokeStyle::new(1.8, Color::from_hex(0xa3be8c)); + + let stream = lapaloma_stream(self.buffer.clone(), stroke) + .background(plot_bg) + .y_range(-1.2, 1.2); + + let fill_pct = (self.buffer.filled_len() * 100) / RING_CAPACITY; + + div() + .size_full() + .bg(theme.bg_app.clone()) + .p(px(16.)) + .flex() + .flex_col() + .gap(px(10.)) + .child( + div() + .text_color(theme.fg_text) + .text_size(px(18.)) + .child("Lapaloma — stream"), + ) + .child( + div() + .flex() + .gap(px(16.)) + .text_size(px(11.)) + .text_color(theme.fg_muted) + .child(format!("cap = {}", RING_CAPACITY)) + .child(format!("head = {}", self.buffer.head())) + .child(format!("filled = {}%", fill_pct)) + .child(format!("t = {}", self.t)) + .child(format!("rev = {}", self.buffer.revision())), + ) + .child(div().w_full().flex_grow().child(stream)) + } +} diff --git a/crates/modules/ui_engine/widgets/lapaloma-stream/Cargo.toml b/crates/modules/ui_engine/widgets/lapaloma-stream/Cargo.toml index 706e66d..1bc09ba 100644 --- a/crates/modules/ui_engine/widgets/lapaloma-stream/Cargo.toml +++ b/crates/modules/ui_engine/widgets/lapaloma-stream/Cargo.toml @@ -10,4 +10,8 @@ description = "Lapaloma — widget de telemetría tipo osciloscopio. Ring buffer [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-stream/src/element.rs b/crates/modules/ui_engine/widgets/lapaloma-stream/src/element.rs new file mode 100644 index 0000000..fae892b --- /dev/null +++ b/crates/modules/ui_engine/widgets/lapaloma-stream/src/element.rs @@ -0,0 +1,208 @@ +//! `LapalomaStreamElement` — Element GPUI para visualización de +//! telemetría con RingBuffer. +//! +//! Modo **sweep** (canónico del osciloscopio): +//! - Los slots `[0, capacity)` tienen `x_norm` fijo precomputado. +//! - El `head` marca el slot donde se va a escribir el próximo +//! sample → visualmente, el "cursor" que separa la traza vieja +//! (a la derecha del head) de la nueva (a la izquierda). +//! - Render en **dos segmentos** split-at-head para evitar la +//! línea horizontal del wraparound. Antes del fill (count < +//! capacity), sólo se pinta `[0, head)`. + +use std::panic; + +use gpui::{ + App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId, + Pixels, Style, Window, +}; + +use lapaloma_core::ring::RingBuffer; +use lapaloma_render::{Canvas, Color, Rect, StrokeStyle, WindowCanvas}; + +/// Element que pinta un `RingBuffer` en modo sweep. +pub struct LapalomaStreamElement { + pub buffer: RingBuffer, + pub stroke: StrokeStyle, + pub background: Option, + /// Rango Y para proyectar los samples a píxeles. Default `-1..1`. + pub y_min: f32, + pub y_max: f32, + pub padding: f32, + /// Scratch reusable entre los dos segmentos del frame. + scratch: Vec, +} + +impl LapalomaStreamElement { + pub fn new(buffer: RingBuffer, stroke: StrokeStyle) -> Self { + Self { + buffer, + stroke, + background: None, + y_min: -1.0, + y_max: 1.0, + padding: 8.0, + scratch: Vec::new(), + } + } + + pub fn background(mut self, color: Color) -> Self { + self.background = Some(color); + self + } + + pub fn y_range(mut self, min: f32, max: f32) -> Self { + debug_assert!(max > min); + self.y_min = min; + self.y_max = max; + self + } + + pub fn padding(mut self, px: f32) -> Self { + self.padding = px; + self + } + + fn plot_rect(&self, bounds: Rect) -> Rect { + Rect::new( + bounds.x + self.padding, + bounds.y + self.padding, + (bounds.w - self.padding * 2.0).max(1.0), + (bounds.h - self.padding * 2.0).max(1.0), + ) + } + +} + +/// Proyecta una slice de coords `[x_norm, y_value, …]` del +/// RingBuffer al sistema de píxeles del plot. `out` se extiende +/// (no se clearea acá; el caller decide). +fn project_segment(segment: &[f32], plot: Rect, y_min: f32, y_max: f32, out: &mut Vec) { + let y_span = y_max - y_min; + if y_span.abs() < 1e-9 { + return; + } + let inv_y_span = 1.0 / y_span; + for chunk in segment.chunks_exact(2) { + let xn = chunk[0]; + let yv = chunk[1]; + let py_norm = (yv - y_min) * inv_y_span; + let px = plot.x + xn * plot.w; + let py = plot.bottom() - py_norm * plot.h; + out.push(px); + out.push(py); + } +} + +impl IntoElement for LapalomaStreamElement { + type Element = Self; + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for LapalomaStreamElement { + 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) { + 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 outer = Rect::new(ox, oy, w, h); + let plot = self.plot_rect(outer); + + let mut canvas = WindowCanvas::new(window); + + if let Some(bg) = self.background { + canvas.fill_rect(outer, bg); + } + + let coords = self.buffer.coords(); + let head = self.buffer.head(); + let cap = self.buffer.capacity(); + + if !self.buffer.is_full() { + // Pre-fill: sólo [0, head). Evita la línea plana del + // 1.0.2 fix del Flutter doc. + let filled = head; + if filled >= 2 { + let slice = &coords[..filled * 2]; + self.scratch.clear(); + project_segment(slice, plot, self.y_min, self.y_max, &mut self.scratch); + canvas.stroke_polyline(&self.scratch, self.stroke); + } + return; + } + + // Ya filled — dos segmentos split-at-head. + let split = head * 2; + // Segmento "viejo": [head*2 .. cap*2) — temporalmente más antiguo. + if split < cap * 2 { + let seg1 = &coords[split..]; + if seg1.len() >= 4 { + self.scratch.clear(); + project_segment(seg1, plot, self.y_min, self.y_max, &mut self.scratch); + canvas.stroke_polyline(&self.scratch, self.stroke); + } + } + // Segmento "nuevo": [0 .. head*2) — más reciente, dibujado a + // la izquierda del cursor. + if split > 0 { + let seg2 = &coords[..split]; + if seg2.len() >= 4 { + self.scratch.clear(); + project_segment(seg2, plot, self.y_min, self.y_max, &mut self.scratch); + canvas.stroke_polyline(&self.scratch, self.stroke); + } + } + } +} + +/// Helper builder-style. +pub fn lapaloma_stream(buffer: RingBuffer, stroke: StrokeStyle) -> LapalomaStreamElement { + LapalomaStreamElement::new(buffer, stroke) +} diff --git a/crates/modules/ui_engine/widgets/lapaloma-stream/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-stream/src/lib.rs index d6c3dca..a54f986 100644 --- a/crates/modules/ui_engine/widgets/lapaloma-stream/src/lib.rs +++ b/crates/modules/ui_engine/widgets/lapaloma-stream/src/lib.rs @@ -1,19 +1,23 @@ //! `lapaloma-stream` — telemetría streaming tipo osciloscopio. //! //! Núcleo: `lapaloma_core::ring::RingBuffer` + render en dos -//! segmentos split-at-head (sweep) o con translate por frame -//! (scroll). +//! segmentos split-at-head (modo sweep). El emisor de samples vive +//! afuera del Element — típicamente en el `Render` host con un +//! timer `cx.background_executor().timer(...)` que llama a +//! `buffer.push(value)` y `cx.notify()` cada N ms. //! -//! Módulos: -//! - **`envelope`** — downsample min/max por columna de pixel. -//! Incremental para sweep, single bounded pass para scroll -//! (ver sección 3.3 del ARCHITECTURE.md). -//! - **`element`** — `Element` GPUI con `Model` -//! observable. El push viene de otro thread; el Element se -//! redibuja sólo cuando `revision` cambió. +//! El Element clona el RingBuffer por frame (para cap = 512 son +//! 4 KB, irrelevante). Para capacidades grandes (100k+) la siguiente +//! optimización es pasar `Arc` con shared read y +//! mutación interna via `Mutex`/`AtomicU64` para el head. #![forbid(unsafe_code)] #![allow(dead_code)] pub mod envelope {} -pub mod element {} + +#[cfg(feature = "gpui")] +pub mod element; + +#[cfg(feature = "gpui")] +pub use element::{lapaloma_stream, LapalomaStreamElement};