gioser

tawasuyu/brahman

Rust 374TOML 138Markdown 40JSON 175081 Definitions

feat(lapaloma-stream): osciloscopio CRT con RingBuffer en sweep mode

- lapaloma-stream: feature `gpui` (default). LapalomaStreamElement
  pinta un RingBuffer en modo sweep — dos polilíneas split-at-head
  (segmento [head..cap) viejo + [0..head) nuevo) para evitar la
  línea horizontal del wraparound. Pre-fill (count < cap) sólo
  pinta [0, head) para evitar el flat-line del 1.0.2 fix.
- y_range configurable, background opcional, padding.
- crates/apps/lapaloma-stream-demo: osciloscopio sintético con
  RingBuffer cap=512. Timer en cx.background_executor que hace
  push(synthesize(t)) + cx.notify() cada 16ms (60 Hz). Señal =
  suma de dos sinusoides desfasadas + jitter determinístico.
  Header muestra cap / head / filled% / t / revision en vivo.
- Workspace: registrada la app lapaloma-stream-demo.

Showcase del P2 zero-alloc: push(v) son 2 writes + 2 increments,
zero allocations per frame, ningún Vec se reasigna.

46 tests verdes (sin cambios; el stream se valida en runtime via demo).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

4796f2652d1b744f9bb8038f6bca15132070d2b8 · by sergio · (5 months ago) · parent 66d18ab47 · browse the tree at this commit

Semantic delta · 25

crates/apps/lapaloma-stream-demo/src/main.rs

crates/modules/ui_engine/widgets/lapaloma-stream/src/element.rs

7 files · +381 −11

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>) -> 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<Self>) -> 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<Color>,
+    /// 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<f32>,
+}
+
+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<f32>) {
+    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<ElementId> {
+        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<Pixels>,
+        _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<Pixels>,
+        _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<RingBuffer>`
-//!   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<RingBuffer>` 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};