From ab03a61db4ff524ffc8d3ff88419d53444e5d76d Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 13 May 2026 03:05:16 +0000 Subject: [PATCH] feat(lapaloma-phosphor): trail CRT con alpha decay + glow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Cargo.lock | 13 +- Cargo.toml | 1 + crates/apps/lapaloma-phosphor-demo/Cargo.toml | 16 + .../apps/lapaloma-phosphor-demo/src/main.rs | 116 ++++++++ .../widgets/lapaloma-phosphor/Cargo.toml | 7 +- .../widgets/lapaloma-phosphor/src/element.rs | 279 ++++++++++++++++++ .../widgets/lapaloma-phosphor/src/lib.rs | 34 ++- 7 files changed, 455 insertions(+), 11 deletions(-) create mode 100644 crates/apps/lapaloma-phosphor-demo/Cargo.toml create mode 100644 crates/apps/lapaloma-phosphor-demo/src/main.rs create mode 100644 crates/modules/ui_engine/widgets/lapaloma-phosphor/src/element.rs 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 { + 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) -> 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, + 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, +} + +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 { + 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 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) { + 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};