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>
This commit is contained in:
sergio
2026-05-13 02:58:25 +00:00
parent 66d18ab47c
commit 4796f2652d
7 changed files with 381 additions and 11 deletions
@@ -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)
}
@@ -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};