tawasuyu

tawasuyu/brahman

Rust 521TOML 202Markdown 57JSON 197505 Sutichaykuna

feat(lapaloma-phosphor): trail CRT con alpha decay + glow

- lapaloma-phosphor: feature `gpui` (default). LapalomaPhosphorElement
  divide el RingBuffer en N segmentos (default 16, configurable) y
  pinta cada uno como una stroke_polyline con alpha = (k+1)/N. El
  segmento más nuevo va con alpha 1.0, el más viejo casi
  transparente — efecto fósforo persistente.
- Cada segmento incluye el primer punto del siguiente para evitar
  gaps visibles entre tramos.
- Wraparound se parte en dos sub-polilíneas (no concatenadas) para
  no introducir la línea horizontal "del slot cap-1 al slot 0".
- Glow opcional: pasada adicional con width × glow_width_mult y
  alpha × glow_alpha — efecto halo CRT.
- crates/apps/lapaloma-phosphor-demo: misma señal sintética que
  stream-demo, paleta verde Tektronix (#9bff8c sobre #050805),
  trail 24 segs + glow 4× α 0.18.

Limitación v0.1: el doc canónico usa triangle strip con per-vertex
color (sección 4.3); GPUI 0.2 no expone esa API directa. La impl
actual es funcionalmente equivalente con N draw calls en lugar de 1.
Cuando wgpu directo esté disponible, swap inmediato sin tocar las
API públicas.

46 tests verdes (sin cambios; phosphor se valida via demo).

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

ab03a61db4ff524ffc8d3ff88419d53444e5d76d · ruwaq sergio · (5 killa ñawpaq) · tayta 4796f2652 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 30

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

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

crates/modules/ui_engine/widgets/lapaloma-phosphor/src/lib.rs

7 qillqakuna · +455 −11

diff --git a/Cargo.lock b/Cargo.lock
index 6960d55..99ca330 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5457,7 +5457,18 @@ dependencies = [
  "gpui",
  "lapaloma-core",
  "lapaloma-render",
- "lapaloma-stream",
+]
+
+[[package]]
+name = "lapaloma-phosphor-demo"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "lapaloma-core",
+ "lapaloma-phosphor",
+ "lapaloma-render",
+ "yahweh-launcher",
+ "yahweh-theme",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 7cacdc9..74acc4b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -138,6 +138,7 @@ members = [
     "crates/apps/gioser-web",
     "crates/apps/lapaloma-demo",
     "crates/apps/lapaloma-stream-demo",
+    "crates/apps/lapaloma-phosphor-demo",
 ]
 
 [workspace.package]
diff --git a/crates/apps/lapaloma-phosphor-demo/Cargo.toml b/crates/apps/lapaloma-phosphor-demo/Cargo.toml
new file mode 100644
index 0000000..65c296a
--- /dev/null
+++ b/crates/apps/lapaloma-phosphor-demo/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "lapaloma-phosphor-demo"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+authors = { workspace = true }
+publish = { workspace = true }
+description = "Lapaloma — demo del trail CRT (phosphor) sobre un RingBuffer streaming a 60Hz. Compará con lapaloma-stream-demo para ver el contraste."
+
+[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-phosphor = { path = "../../modules/ui_engine/widgets/lapaloma-phosphor" }
diff --git a/crates/apps/lapaloma-phosphor-demo/src/main.rs b/crates/apps/lapaloma-phosphor-demo/src/main.rs
new file mode 100644
index 0000000..4b1d23a
--- /dev/null
+++ b/crates/apps/lapaloma-phosphor-demo/src/main.rs
@@ -0,0 +1,116 @@
+//! `lapaloma-phosphor-demo` — osciloscopio con trail CRT.
+//!
+//! Igual setup que `lapaloma-stream-demo` (RingBuffer 512 +
+//! timer 60 Hz) pero el render usa `LapalomaPhosphorElement`:
+//! el trail decae en alpha del cursor hacia atrás y arrastra un
+//! halo (glow). Visualmente queda como un osciloscopio analógico
+//! con fósforo persistente.
+//!
+//! Sliders para `trail_segments` y `glow` se dejan para más
+//! adelante; este demo usa los defaults.
+
+use std::time::Duration;
+
+use gpui::{div, prelude::*, px, Context, IntoElement, Render, Window};
+
+use lapaloma_core::ring::RingBuffer;
+use lapaloma_phosphor::lapaloma_phosphor;
+use lapaloma_render::{Color, StrokeStyle};
+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 — phosphor trail (CRT 60 Hz)",
+        (900., 480.),
+        PhosphorDemo::new,
+    );
+}
+
+struct PhosphorDemo {
+    buffer: RingBuffer,
+    t: u64,
+}
+
+impl PhosphorDemo {
+    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);
+    }
+}
+
+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 PhosphorDemo {
+    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let theme = Theme::global(cx).clone();
+
+        // Verde fósforo CRT clásico (Tektronix vibes).
+        let plot_bg = Color::rgba(0.03, 0.05, 0.04, 1.0);
+        let trace = StrokeStyle::new(1.6, Color::from_hex(0x9bff8c));
+
+        let phosphor = lapaloma_phosphor(self.buffer.clone(), trace)
+            .background(plot_bg)
+            .y_range(-1.2, 1.2)
+            .trail_segments(24)
+            .glow(4.0, 0.18);
+
+        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 — phosphor"),
+            )
+            .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("trail = 24 segs")
+                    .child("glow = 4× / α 0.18")
+                    .child(format!("t = {}", self.t)),
+            )
+            .child(div().w_full().flex_grow().child(phosphor))
+    }
+}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-phosphor/Cargo.toml b/crates/modules/ui_engine/widgets/lapaloma-phosphor/Cargo.toml
index f29e906..dd181bc 100644
--- a/crates/modules/ui_engine/widgets/lapaloma-phosphor/Cargo.toml
+++ b/crates/modules/ui_engine/widgets/lapaloma-phosphor/Cargo.toml
@@ -10,5 +10,8 @@ description = "Lapaloma — decoración CRT sobre lapaloma-stream: trail con alp
 [dependencies]
 lapaloma-core = { path = "../../libs/lapaloma-core" }
 lapaloma-render = { path = "../lapaloma-render" }
-lapaloma-stream = { path = "../lapaloma-stream" }
-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-phosphor/src/element.rs b/crates/modules/ui_engine/widgets/lapaloma-phosphor/src/element.rs
new file mode 100644
index 0000000..9da791b
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-phosphor/src/element.rs
@@ -0,0 +1,279 @@
+//! `LapalomaPhosphorElement` — Element GPUI con trail CRT.
+//!
+//! El render pinta el RingBuffer como N segmentos polilíneas con
+//! alpha decreciente del más nuevo al más viejo. Wraparound se
+//! parte en dos sub-polilíneas para no introducir la línea
+//! horizontal "del slot cap-1 al slot 0".
+
+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};
+
+/// Cantidad de tramos del trail. Más tramos = gradiente más suave,
+/// más draw calls. 16 cubre la mayoría de los casos sin ser caro.
+const DEFAULT_TRAIL_SEGMENTS: usize = 16;
+
+pub struct LapalomaPhosphorElement {
+    pub buffer: RingBuffer,
+    /// Color y ancho base. El alpha se modula por tramo;
+    /// `base_stroke.color.a` es el alpha máximo (cabeza del trail).
+    pub base_stroke: StrokeStyle,
+    pub background: Option<Color>,
+    pub y_min: f32,
+    pub y_max: f32,
+    pub padding: f32,
+    pub trail_segments: usize,
+    /// Si > 0, se aplica una pasada adicional con `width × glow_width_mult`
+    /// y `alpha × glow_alpha` debajo del trazo principal — efecto halo CRT.
+    pub glow_width_mult: f32,
+    pub glow_alpha: f32,
+    scratch: Vec<f32>,
+}
+
+impl LapalomaPhosphorElement {
+    pub fn new(buffer: RingBuffer, base_stroke: StrokeStyle) -> Self {
+        Self {
+            buffer,
+            base_stroke,
+            background: None,
+            y_min: -1.0,
+            y_max: 1.0,
+            padding: 8.0,
+            trail_segments: DEFAULT_TRAIL_SEGMENTS,
+            glow_width_mult: 3.0,
+            glow_alpha: 0.25,
+            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 trail_segments(mut self, n: usize) -> Self {
+        self.trail_segments = n.max(2);
+        self
+    }
+
+    pub fn glow(mut self, width_mult: f32, alpha: f32) -> Self {
+        self.glow_width_mult = width_mult;
+        self.glow_alpha = alpha;
+        self
+    }
+
+    pub fn no_glow(mut self) -> Self {
+        self.glow_width_mult = 0.0;
+        self.glow_alpha = 0.0;
+        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),
+        )
+    }
+}
+
+impl IntoElement for LapalomaPhosphorElement {
+    type Element = Self;
+    fn into_element(self) -> Self::Element {
+        self
+    }
+}
+
+impl Element for LapalomaPhosphorElement {
+    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 filled = self.buffer.filled_len();
+        if filled < 2 {
+            return;
+        }
+
+        let cap = self.buffer.capacity();
+        let head = self.buffer.head();
+        let coords = self.buffer.coords();
+
+        // El slot del sample temporalmente más viejo:
+        // - is_full → `head` (el siguiente a sobrescribirse).
+        // - !is_full → 0.
+        let start_slot = if self.buffer.is_full() { head } else { 0 };
+
+        let n_segs = self.trail_segments.min(filled / 2).max(2);
+        let base_per_seg = filled / n_segs;
+        let glow_enabled = self.glow_alpha > 0.0 && self.glow_width_mult > 1.0;
+
+        for k in 0..n_segs {
+            // Rango temporal del segmento. El segmento `k` cubre los
+            // samples [k*base_per_seg, (k+1)*base_per_seg). El último
+            // incluye el remainder.
+            let t_lo = k * base_per_seg;
+            let t_hi = if k == n_segs - 1 {
+                filled
+            } else {
+                // +1 incluye el primer sample del siguiente segmento
+                // para que las polilíneas se "toquen" sin gap visual.
+                ((k + 1) * base_per_seg) + 1
+            };
+            if t_hi <= t_lo + 1 {
+                continue;
+            }
+
+            // Alpha decrece linealmente del más nuevo al más viejo.
+            // k = n_segs - 1 → 1.0; k = 0 → 1/n_segs.
+            let life = (k as f32 + 1.0) / n_segs as f32;
+            let alpha = self.base_stroke.color.a * life;
+            let mut color = self.base_stroke.color;
+            color.a = alpha;
+            let stroke = StrokeStyle::new(self.base_stroke.width, color);
+
+            // Glow underneath, mismo path con más ancho y menos alpha.
+            let glow_stroke = if glow_enabled {
+                let mut gc = color;
+                gc.a *= self.glow_alpha;
+                Some(StrokeStyle::new(
+                    self.base_stroke.width * self.glow_width_mult,
+                    gc,
+                ))
+            } else {
+                None
+            };
+
+            // Proyectar el rango temporal a slots físicos, partiendo
+            // si cruzamos el final del buffer.
+            let seg_len = t_hi - t_lo;
+            let abs_start = (start_slot + t_lo) % cap;
+            let contiguous_len = cap - abs_start;
+
+            if seg_len <= contiguous_len {
+                let slice = &coords[abs_start * 2..(abs_start + seg_len) * 2];
+                self.scratch.clear();
+                project_segment(slice, plot, self.y_min, self.y_max, &mut self.scratch);
+                if self.scratch.len() >= 4 {
+                    if let Some(gs) = glow_stroke {
+                        canvas.stroke_polyline(&self.scratch, gs);
+                    }
+                    canvas.stroke_polyline(&self.scratch, stroke);
+                }
+            } else {
+                // Wraparound: dos sub-polilíneas separadas.
+                let slice_a = &coords[abs_start * 2..];
+                self.scratch.clear();
+                project_segment(slice_a, plot, self.y_min, self.y_max, &mut self.scratch);
+                if self.scratch.len() >= 4 {
+                    if let Some(gs) = glow_stroke {
+                        canvas.stroke_polyline(&self.scratch, gs);
+                    }
+                    canvas.stroke_polyline(&self.scratch, stroke);
+                }
+
+                let remaining = seg_len - contiguous_len;
+                let slice_b = &coords[..remaining * 2];
+                self.scratch.clear();
+                project_segment(slice_b, plot, self.y_min, self.y_max, &mut self.scratch);
+                if self.scratch.len() >= 4 {
+                    if let Some(gs) = glow_stroke {
+                        canvas.stroke_polyline(&self.scratch, gs);
+                    }
+                    canvas.stroke_polyline(&self.scratch, stroke);
+                }
+            }
+        }
+    }
+}
+
+/// Helper builder.
+pub fn lapaloma_phosphor(
+    buffer: RingBuffer,
+    base_stroke: StrokeStyle,
+) -> LapalomaPhosphorElement {
+    LapalomaPhosphorElement::new(buffer, base_stroke)
+}
+
+/// Proyecta `[x_norm, y_value, …]` del ring a píxeles del plot.
+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 = 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;
+        out.push(plot.x + xn * plot.w);
+        out.push(plot.bottom() - py_norm * plot.h);
+    }
+}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-phosphor/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-phosphor/src/lib.rs
index 1865107..b6ebdb3 100644
--- a/crates/modules/ui_engine/widgets/lapaloma-phosphor/src/lib.rs
+++ b/crates/modules/ui_engine/widgets/lapaloma-phosphor/src/lib.rs
@@ -1,15 +1,33 @@
-//! `lapaloma-phosphor` — decoración CRT sobre `lapaloma-stream`.
+//! `lapaloma-phosphor` — decoración CRT sobre `lapaloma_core::RingBuffer`.
 //!
-//! Three pieces (sección 4.3):
-//! - **`trail`** — cada sample como 2 vértices ±half_width, triangle
-//!   strip con color por vértice; alpha = 1 - age/trail_samples.
-//! - **`ghost`** — render con offset/blur del trail anterior.
-//! - **`magnetic_anchor`** — anotaciones ancladas a sample index
-//!   absoluto, no a screen pos (sección 5.5).
+//! El "real" oscilloscope-trail effect de la sección 4.3 del
+//! ARCHITECTURE.md renderea cada sample como **2 vértices**
+//! (top y bottom, offset ±half_width) atados a un triangle strip
+//! con per-vertex color. GPUI 0.2 no expone triangle strips con
+//! atributos de vértice de forma directa.
+//!
+//! v0.1 implementa el efecto con un approach distinto pero
+//! visualmente similar: el trail se divide en N **segmentos**
+//! consecutivos del ring, cada uno se pinta como una `stroke_polyline`
+//! con alpha decreciente del más nuevo (1.0) al más viejo (≈ 0).
+//! Cada segmento incluye el primer sample del siguiente para no
+//! dejar gaps visibles entre tramos.
+//!
+//! Coste: N draw calls por frame en lugar de 2 del stream simple.
+//! Para N = 16 y ring cap = 512 son sub-millisecond en cualquier
+//! laptop moderna.
+//!
+//! Cuando GPUI/wgpu expongan triangle strip + per-vertex color,
+//! la siguiente fase reemplaza esta impl por la canónica.
 
 #![forbid(unsafe_code)]
 #![allow(dead_code)]
 
-pub mod trail {}
 pub mod ghost {}
 pub mod magnetic_anchor {}
+
+#[cfg(feature = "gpui")]
+pub mod element;
+
+#[cfg(feature = "gpui")]
+pub use element::{lapaloma_phosphor, LapalomaPhosphorElement};