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:
Generated
+12
@@ -5486,6 +5486,18 @@ dependencies = [
|
|||||||
"lapaloma-render",
|
"lapaloma-render",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lapaloma-stream-demo"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"gpui",
|
||||||
|
"lapaloma-core",
|
||||||
|
"lapaloma-render",
|
||||||
|
"lapaloma-stream",
|
||||||
|
"yahweh-launcher",
|
||||||
|
"yahweh-theme",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lapaloma-treemap"
|
name = "lapaloma-treemap"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ members = [
|
|||||||
"crates/apps/shipote-shell",
|
"crates/apps/shipote-shell",
|
||||||
"crates/apps/gioser-web",
|
"crates/apps/gioser-web",
|
||||||
"crates/apps/lapaloma-demo",
|
"crates/apps/lapaloma-demo",
|
||||||
|
"crates/apps/lapaloma-stream-demo",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,4 +10,8 @@ description = "Lapaloma — widget de telemetría tipo osciloscopio. Ring buffer
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
lapaloma-core = { path = "../../libs/lapaloma-core" }
|
lapaloma-core = { path = "../../libs/lapaloma-core" }
|
||||||
lapaloma-render = { path = "../lapaloma-render" }
|
lapaloma-render = { path = "../lapaloma-render" }
|
||||||
gpui = { workspace = true }
|
gpui = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["gpui"]
|
||||||
|
gpui = ["dep:gpui", "lapaloma-render/gpui"]
|
||||||
|
|||||||
@@ -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.
|
//! `lapaloma-stream` — telemetría streaming tipo osciloscopio.
|
||||||
//!
|
//!
|
||||||
//! Núcleo: `lapaloma_core::ring::RingBuffer` + render en dos
|
//! Núcleo: `lapaloma_core::ring::RingBuffer` + render en dos
|
||||||
//! segmentos split-at-head (sweep) o con translate por frame
|
//! segmentos split-at-head (modo sweep). El emisor de samples vive
|
||||||
//! (scroll).
|
//! 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:
|
//! El Element clona el RingBuffer por frame (para cap = 512 son
|
||||||
//! - **`envelope`** — downsample min/max por columna de pixel.
|
//! 4 KB, irrelevante). Para capacidades grandes (100k+) la siguiente
|
||||||
//! Incremental para sweep, single bounded pass para scroll
|
//! optimización es pasar `Arc<RingBuffer>` con shared read y
|
||||||
//! (ver sección 3.3 del ARCHITECTURE.md).
|
//! mutación interna via `Mutex`/`AtomicU64` para el head.
|
||||||
//! - **`element`** — `Element` GPUI con `Model<RingBuffer>`
|
|
||||||
//! observable. El push viene de otro thread; el Element se
|
|
||||||
//! redibuja sólo cuando `revision` cambió.
|
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
pub mod envelope {}
|
pub mod envelope {}
|
||||||
pub mod element {}
|
|
||||||
|
#[cfg(feature = "gpui")]
|
||||||
|
pub mod element;
|
||||||
|
|
||||||
|
#[cfg(feature = "gpui")]
|
||||||
|
pub use element::{lapaloma_stream, LapalomaStreamElement};
|
||||||
|
|||||||
Reference in New Issue
Block a user