Merge pull request #68 from levineuwirth/session-gpu-wavy-squiggles
GPU diagnostic squiggles: wavy shader + optimistic clear-on-edit
This commit is contained in:
commit
fde5453721
|
|
@ -0,0 +1,62 @@
|
||||||
|
# pmacs-gpu wavy squiggles — framing pass
|
||||||
|
|
||||||
|
Date: 2026-06-15. The diagnostic-parity session (PR #65) deferred the
|
||||||
|
wavy look: diagnostics underline with a straight 2px quad bar
|
||||||
|
(`DIAG_UNDERLINE_PX`), with the framing note "wavy needs a shader or
|
||||||
|
texture and buys nothing until the straight bar is proven." The bar is
|
||||||
|
proven; this is the shader.
|
||||||
|
|
||||||
|
## Survey facts
|
||||||
|
|
||||||
|
- The single `QuadRenderer` (one solid-color shader) draws every quad:
|
||||||
|
selection/current-line washes, caret, minimap, status band, and the
|
||||||
|
diagnostic bars. Its vertex is `pos(2) + color(4)` = 24 bytes.
|
||||||
|
- Diagnostic bars are emitted in `collect_own_decoration_rects` via
|
||||||
|
`push_glyph_extent_rects(.., Some(DIAG_UNDERLINE_PX))` and ride the
|
||||||
|
`bg_vertices` batch (drawn *under* the text).
|
||||||
|
- `fwidth`/`smoothstep` are core WGSL — anti-aliasing needs no feature
|
||||||
|
flag or MSAA change.
|
||||||
|
|
||||||
|
## Q#W1 — mechanism
|
||||||
|
|
||||||
|
**Stance: a dedicated squiggle pipeline, not geometry.** A zigzag of
|
||||||
|
many tiny quads would need no shader but reads as a sawtooth and
|
||||||
|
multiplies vertex count per underline. Instead a second pipeline
|
||||||
|
(`SquiggleRenderer`) whose fragment shader computes
|
||||||
|
`A·sin(x·2π/λ)` and alphas pixels by distance to the curve
|
||||||
|
(`1 - smoothstep(thickness±fwidth, dist)`). Six vertices per glyph-run
|
||||||
|
extent, same as the bar — the geometry path (`push_glyph_extent_rects`)
|
||||||
|
is reused unchanged; only the vertex *format* differs (adds a `uv`:
|
||||||
|
absolute screen-x for continuous phase, signed px from the band
|
||||||
|
centerline for the cross-axis). The band grows from 2px to ~6px to
|
||||||
|
contain the wave.
|
||||||
|
|
||||||
|
## Q#W2 — routing & z-order
|
||||||
|
|
||||||
|
**Stance: split the diagnostic underline out of the wash batch; keep
|
||||||
|
it under the text.** Washes stay solid quads in `bg_vertices`;
|
||||||
|
squiggles get their own `squiggle_vertex_bytes` → own reusable buffer
|
||||||
|
→ own draw, slotted between the bg quads and the text (same z-slot the
|
||||||
|
bar had — descenders paint over the wave, the conventional
|
||||||
|
spell-check look). `decoration_kind_to_underline_color` is unchanged,
|
||||||
|
so the severity palette and the minimap marks are untouched.
|
||||||
|
|
||||||
|
## Predicted findings (categorical bets)
|
||||||
|
|
||||||
|
1. **Placement/amplitude tuning**: wavelength, amplitude, or the
|
||||||
|
band's vertical offset reads wrong at the default font size — one
|
||||||
|
round of "too tight / too tall / too faint" feedback.
|
||||||
|
2. **Phase seams**: a diagnostic spanning two glyph *runs* (or two
|
||||||
|
adjacent diagnostics) emits separate rects; if phase isn't keyed to
|
||||||
|
absolute screen-x the waves don't line up at the seam. (Mitigated
|
||||||
|
by using screen-x as the phase input — but worth eyeballing.)
|
||||||
|
3. **DPI/hidpi**: amplitude and thickness are in logical px; on a
|
||||||
|
fractional-scale surface the AA may thin or the wave may alias.
|
||||||
|
|
||||||
|
## Session plan
|
||||||
|
|
||||||
|
One commit: squiggle shader + pipeline + vertex format, route the
|
||||||
|
underline out of the wash batch, rename `DIAG_UNDERLINE_PX` →
|
||||||
|
`DIAG_SQUIGGLE_PX`, pure-fn test on the UV emission. Manual
|
||||||
|
validation: a file with an error + warning + hint on adjacent lines,
|
||||||
|
a diagnostic spanning a multi-glyph token, and a resize.
|
||||||
|
|
@ -136,6 +136,51 @@ const QUAD_VERTEX_STRIDE: wgpu::BufferAddress = 24;
|
||||||
const QUAD_VERTEX_ATTRS: [wgpu::VertexAttribute; 2] =
|
const QUAD_VERTEX_ATTRS: [wgpu::VertexAttribute; 2] =
|
||||||
wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x4];
|
wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x4];
|
||||||
|
|
||||||
|
/// Diagnostic squiggle shader (Q#W1). The vertex carries, beyond NDC
|
||||||
|
/// position and color, a `uv`: `uv.x` is the absolute screen-space
|
||||||
|
/// pixel x (so the wave's phase is continuous across separately
|
||||||
|
/// emitted glyph-run rects), `uv.y` is the signed pixel offset from
|
||||||
|
/// the band's vertical centerline. The fragment draws an
|
||||||
|
/// anti-aliased sine: alpha falls off with distance to the curve via
|
||||||
|
/// `fwidth`/`smoothstep` (both core WGSL — no MSAA or feature flag).
|
||||||
|
const SQUIGGLE_SHADER: &str = r"
|
||||||
|
struct VertexOut {
|
||||||
|
@builtin(position) pos: vec4<f32>,
|
||||||
|
@location(0) uv: vec2<f32>,
|
||||||
|
@location(1) color: vec4<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_main(
|
||||||
|
@location(0) pos: vec2<f32>,
|
||||||
|
@location(1) uv: vec2<f32>,
|
||||||
|
@location(2) color: vec4<f32>,
|
||||||
|
) -> VertexOut {
|
||||||
|
var out: VertexOut;
|
||||||
|
out.pos = vec4<f32>(pos, 0.0, 1.0);
|
||||||
|
out.uv = uv;
|
||||||
|
out.color = color;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
|
||||||
|
let wavelength = 6.0; // px per full sine period
|
||||||
|
let amplitude = 1.4; // px peak from centerline
|
||||||
|
let thickness = 1.0; // px stroke half-width
|
||||||
|
let two_pi = 6.2831853;
|
||||||
|
let wave = amplitude * sin(in.uv.x * (two_pi / wavelength));
|
||||||
|
let dist = abs(in.uv.y - wave);
|
||||||
|
let aa = fwidth(dist);
|
||||||
|
let alpha = 1.0 - smoothstep(thickness - aa, thickness + aa, dist);
|
||||||
|
return vec4<f32>(in.color.rgb, in.color.a * alpha);
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
const SQUIGGLE_VERTEX_STRIDE: wgpu::BufferAddress = 32;
|
||||||
|
const SQUIGGLE_VERTEX_ATTRS: [wgpu::VertexAttribute; 3] =
|
||||||
|
wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4];
|
||||||
|
|
||||||
/// Text the hello-world (and attach-pre-snapshot / attach-failed)
|
/// Text the hello-world (and attach-pre-snapshot / attach-failed)
|
||||||
/// modes render. Once the daemon's `BufferSnapshot` arrives the
|
/// modes render. Once the daemon's `BufferSnapshot` arrives the
|
||||||
/// rendered text becomes the rope contents instead.
|
/// rendered text becomes the rope contents instead.
|
||||||
|
|
@ -259,6 +304,7 @@ struct State {
|
||||||
atlas: TextAtlas,
|
atlas: TextAtlas,
|
||||||
text_renderer: TextRenderer,
|
text_renderer: TextRenderer,
|
||||||
quad_renderer: QuadRenderer,
|
quad_renderer: QuadRenderer,
|
||||||
|
squiggle_renderer: SquiggleRenderer,
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
/// What the buffer is currently shaped to. Held so we can detect
|
/// What the buffer is currently shaped to. Held so we can detect
|
||||||
/// no-op updates and skip the re-shape.
|
/// no-op updates and skip the re-shape.
|
||||||
|
|
@ -465,6 +511,7 @@ struct State {
|
||||||
/// Absolute source-line index of `buffer.lines[0]`.
|
/// Absolute source-line index of `buffer.lines[0]`.
|
||||||
shaped_top: usize,
|
shaped_top: usize,
|
||||||
bg_vertex_buffer: ReusableVertexBuffer,
|
bg_vertex_buffer: ReusableVertexBuffer,
|
||||||
|
squiggle_vertex_buffer: ReusableVertexBuffer,
|
||||||
caret_vertex_buffer: ReusableVertexBuffer,
|
caret_vertex_buffer: ReusableVertexBuffer,
|
||||||
minimap_vertex_buffer: ReusableVertexBuffer,
|
minimap_vertex_buffer: ReusableVertexBuffer,
|
||||||
/// Q#S2 — the status band's one-line text. Shaped only when the
|
/// Q#S2 — the status band's one-line text. Shaped only when the
|
||||||
|
|
@ -1137,6 +1184,69 @@ impl QuadRenderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Diagnostic-squiggle pipeline (Q#W1). Parallel to [`QuadRenderer`]
|
||||||
|
/// but with the [`SQUIGGLE_SHADER`] / [`SQUIGGLE_VERTEX_ATTRS`] vertex
|
||||||
|
/// format that carries the per-fragment `uv` the sine fragment shader
|
||||||
|
/// needs.
|
||||||
|
struct SquiggleRenderer {
|
||||||
|
pipeline: wgpu::RenderPipeline,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SquiggleRenderer {
|
||||||
|
fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
|
||||||
|
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("pmacs-gpu squiggle shader"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(SQUIGGLE_SHADER.into()),
|
||||||
|
});
|
||||||
|
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
label: Some("pmacs-gpu squiggle pipeline layout"),
|
||||||
|
bind_group_layouts: &[],
|
||||||
|
immediate_size: 0,
|
||||||
|
});
|
||||||
|
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("pmacs-gpu squiggle pipeline"),
|
||||||
|
layout: Some(&layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: Some("vs_main"),
|
||||||
|
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||||||
|
buffers: &[wgpu::VertexBufferLayout {
|
||||||
|
array_stride: SQUIGGLE_VERTEX_STRIDE,
|
||||||
|
step_mode: wgpu::VertexStepMode::Vertex,
|
||||||
|
attributes: &SQUIGGLE_VERTEX_ATTRS,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: Some("fs_main"),
|
||||||
|
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||||||
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
|
format: surface_format,
|
||||||
|
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState::default(),
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: MultisampleState::default(),
|
||||||
|
multiview_mask: None,
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
Self { pipeline }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render<'pass>(
|
||||||
|
&'pass self,
|
||||||
|
pass: &mut wgpu::RenderPass<'pass>,
|
||||||
|
vertex_buffer: &'pass wgpu::Buffer,
|
||||||
|
vertex_count: u32,
|
||||||
|
) {
|
||||||
|
pass.set_pipeline(&self.pipeline);
|
||||||
|
pass.set_vertex_buffer(0, vertex_buffer.slice(..));
|
||||||
|
pass.draw(0..vertex_count, 0..1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl State {
|
impl State {
|
||||||
#[allow(clippy::too_many_lines)] // linear GPU/font/surface setup; splitting would obscure ordering.
|
#[allow(clippy::too_many_lines)] // linear GPU/font/surface setup; splitting would obscure ordering.
|
||||||
fn new(event_loop: &ActiveEventLoop, initial_text: &str) -> Self {
|
fn new(event_loop: &ActiveEventLoop, initial_text: &str) -> Self {
|
||||||
|
|
@ -1207,6 +1317,7 @@ impl State {
|
||||||
let text_renderer =
|
let text_renderer =
|
||||||
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
|
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
|
||||||
let quad_renderer = QuadRenderer::new(&device, surface_format);
|
let quad_renderer = QuadRenderer::new(&device, surface_format);
|
||||||
|
let squiggle_renderer = SquiggleRenderer::new(&device, surface_format);
|
||||||
|
|
||||||
// Smaller font in attach mode (file contents tend to be more
|
// Smaller font in attach mode (file contents tend to be more
|
||||||
// than one line); larger only fits "hello, pmacs"-shaped
|
// than one line); larger only fits "hello, pmacs"-shaped
|
||||||
|
|
@ -1258,6 +1369,7 @@ impl State {
|
||||||
atlas,
|
atlas,
|
||||||
text_renderer,
|
text_renderer,
|
||||||
quad_renderer,
|
quad_renderer,
|
||||||
|
squiggle_renderer,
|
||||||
buffer,
|
buffer,
|
||||||
current_text: initial_text.to_owned(),
|
current_text: initial_text.to_owned(),
|
||||||
current_line_starts,
|
current_line_starts,
|
||||||
|
|
@ -1297,6 +1409,7 @@ impl State {
|
||||||
line_chunk_cache: Vec::new(),
|
line_chunk_cache: Vec::new(),
|
||||||
shaped_top: 0,
|
shaped_top: 0,
|
||||||
bg_vertex_buffer: ReusableVertexBuffer::new(),
|
bg_vertex_buffer: ReusableVertexBuffer::new(),
|
||||||
|
squiggle_vertex_buffer: ReusableVertexBuffer::new(),
|
||||||
caret_vertex_buffer: ReusableVertexBuffer::new(),
|
caret_vertex_buffer: ReusableVertexBuffer::new(),
|
||||||
minimap_vertex_buffer: ReusableVertexBuffer::new(),
|
minimap_vertex_buffer: ReusableVertexBuffer::new(),
|
||||||
status_buffer,
|
status_buffer,
|
||||||
|
|
@ -2813,6 +2926,21 @@ impl State {
|
||||||
&bg_vertices,
|
&bg_vertices,
|
||||||
)
|
)
|
||||||
.cloned();
|
.cloned();
|
||||||
|
// Diagnostic squiggles (Q#W1): own pipeline + buffer, drawn
|
||||||
|
// between the wash quads and the text (under the glyphs, the
|
||||||
|
// z-slot the straight bar held).
|
||||||
|
let squiggle_vertices = self.squiggle_vertex_bytes();
|
||||||
|
let squiggle_vertex_count =
|
||||||
|
(squiggle_vertices.len() / SQUIGGLE_VERTEX_STRIDE as usize) as u32;
|
||||||
|
let squiggle_buffer = self
|
||||||
|
.squiggle_vertex_buffer
|
||||||
|
.upload(
|
||||||
|
&self.device,
|
||||||
|
&self.queue,
|
||||||
|
"pmacs-gpu diagnostic squiggles",
|
||||||
|
&squiggle_vertices,
|
||||||
|
)
|
||||||
|
.cloned();
|
||||||
let caret_vertices = self.caret_vertex_bytes();
|
let caret_vertices = self.caret_vertex_bytes();
|
||||||
let caret_vertex_count = (caret_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32;
|
let caret_vertex_count = (caret_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32;
|
||||||
let caret_buffer = self
|
let caret_buffer = self
|
||||||
|
|
@ -2956,6 +3084,11 @@ impl State {
|
||||||
self.quad_renderer
|
self.quad_renderer
|
||||||
.render(&mut pass, vertex_buffer, bg_vertex_count);
|
.render(&mut pass, vertex_buffer, bg_vertex_count);
|
||||||
}
|
}
|
||||||
|
// Diagnostic squiggles under the glyphs (Q#W1).
|
||||||
|
if let Some(vertex_buffer) = squiggle_buffer.as_ref() {
|
||||||
|
self.squiggle_renderer
|
||||||
|
.render(&mut pass, vertex_buffer, squiggle_vertex_count);
|
||||||
|
}
|
||||||
self.text_renderer
|
self.text_renderer
|
||||||
.render(&self.atlas, &self.viewport, &mut pass)
|
.render(&self.atlas, &self.viewport, &mut pass)
|
||||||
.expect("text_renderer render");
|
.expect("text_renderer render");
|
||||||
|
|
@ -3076,20 +3209,47 @@ impl State {
|
||||||
let Some((lo, hi)) = clip_rebase_range(d.range.start, d.range.end, vstart, vend) else {
|
let Some((lo, hi)) = clip_rebase_range(d.range.start, d.range.end, vstart, vend) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
// Diagnostic underlines are squiggles now, drawn by their
|
||||||
|
// own pipeline (`squiggle_vertex_bytes`); only the solid
|
||||||
|
// washes belong in this quad batch.
|
||||||
if let Some(color) = decoration_kind_to_bg_color(d.kind) {
|
if let Some(color) = decoration_kind_to_bg_color(d.kind) {
|
||||||
self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color, None);
|
self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color, None);
|
||||||
}
|
}
|
||||||
if let Some(color) = decoration_kind_to_underline_color(d.kind) {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vertex bytes for diagnostic squiggles (Q#W1), drawn by the
|
||||||
|
/// dedicated [`SquiggleRenderer`] pipeline rather than the solid
|
||||||
|
/// quad batch. Geometry is the same bottom-hugging glyph-extent
|
||||||
|
/// band as the old straight bar — only the vertex *format* differs
|
||||||
|
/// (carries the `uv` the sine fragment shader needs).
|
||||||
|
fn squiggle_vertex_bytes(&self) -> Vec<u8> {
|
||||||
|
if self.current_buffer_id.is_none() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let (vstart, vend) = self.view_range;
|
||||||
|
if vend <= vstart {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let slice = &self.current_text[vstart as usize..vend as usize];
|
||||||
|
let line_offsets = line_byte_offsets(slice);
|
||||||
|
let mut rects = Vec::new();
|
||||||
|
for d in &self.current_decorations {
|
||||||
|
let Some(color) = decoration_kind_to_underline_color(d.kind) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some((lo, hi)) = clip_rebase_range(d.range.start, d.range.end, vstart, vend) {
|
||||||
self.push_glyph_extent_rects(
|
self.push_glyph_extent_rects(
|
||||||
rects,
|
&mut rects,
|
||||||
line_offsets,
|
&line_offsets,
|
||||||
lo,
|
lo,
|
||||||
hi,
|
hi,
|
||||||
color,
|
color,
|
||||||
Some(DIAG_UNDERLINE_PX),
|
Some(DIAG_SQUIGGLE_PX),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
squiggles_to_vertex_bytes(&rects, self.config.width, self.config.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Peer cursor-line + selection washes from `PresenceUpdate`
|
/// Peer cursor-line + selection washes from `PresenceUpdate`
|
||||||
|
|
@ -3226,9 +3386,10 @@ impl State {
|
||||||
if let (Some(x0), Some(x1)) = (min_x, max_x)
|
if let (Some(x0), Some(x1)) = (min_x, max_x)
|
||||||
&& x1 > x0
|
&& x1 > x0
|
||||||
{
|
{
|
||||||
// `bar_px`: an underline bar hugging the bottom of the
|
// `bar_px`: a band hugging the bottom of the line box
|
||||||
// line box instead of a full-height wash — the GPU's
|
// instead of a full-height wash — the diagnostic
|
||||||
// diagnostic squiggle (T M4.6 parity, straight-bar v1).
|
// squiggle's geometry (T M4.6 parity; the squiggle
|
||||||
|
// shape comes from the fragment shader, Q#W1).
|
||||||
let (y, h) = match bar_px {
|
let (y, h) = match bar_px {
|
||||||
Some(bar) => (TEXT_TOP + run.line_top + run.line_height - bar, bar),
|
Some(bar) => (TEXT_TOP + run.line_top + run.line_height - bar, bar),
|
||||||
None => (TEXT_TOP + run.line_top, run.line_height),
|
None => (TEXT_TOP + run.line_top, run.line_height),
|
||||||
|
|
@ -4220,6 +4381,19 @@ fn translate_style_spans(spans: &mut Vec<StyleSpan>, edit: TextProjectionEdit) {
|
||||||
fn translate_decorations(decorations: &mut Vec<Decoration>, edit: TextProjectionEdit) {
|
fn translate_decorations(decorations: &mut Vec<Decoration>, edit: TextProjectionEdit) {
|
||||||
let mut translated = Vec::with_capacity(decorations.len());
|
let mut translated = Vec::with_capacity(decorations.len());
|
||||||
for mut decoration in decorations.drain(..) {
|
for mut decoration in decorations.drain(..) {
|
||||||
|
// Optimistic clear (diagnostics only): an edit that touches a
|
||||||
|
// diagnostic's range invalidates it locally, so drop the
|
||||||
|
// squiggle now instead of holding a stale wave over the text
|
||||||
|
// you just changed until the LSP re-analyzes and republishes.
|
||||||
|
// Scoped to the *touched* diagnostic — an error elsewhere
|
||||||
|
// still translates and holds, so the no-blink benefit of the
|
||||||
|
// producer's hold-while-stale survives. Non-diagnostic
|
||||||
|
// decorations (selection / current-line) always translate.
|
||||||
|
if decoration_kind_to_underline_color(decoration.kind).is_some()
|
||||||
|
&& edit_touches_range(decoration.range, edit)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Some(range) = translate_byte_range(decoration.range, edit) {
|
if let Some(range) = translate_byte_range(decoration.range, edit) {
|
||||||
decoration.range = range;
|
decoration.range = range;
|
||||||
translated.push(decoration);
|
translated.push(decoration);
|
||||||
|
|
@ -4228,6 +4402,14 @@ fn translate_decorations(decorations: &mut Vec<Decoration>, edit: TextProjection
|
||||||
*decorations = translated;
|
*decorations = translated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when `edit` touches `range` in the pre-edit coordinate space.
|
||||||
|
/// An insert (`old_end == start`) touches when its point lies within
|
||||||
|
/// `[start, end]` (inclusive — typing at either edge of an error
|
||||||
|
/// token counts); a delete/replace touches when its span overlaps.
|
||||||
|
fn edit_touches_range(range: ByteRange, edit: TextProjectionEdit) -> bool {
|
||||||
|
edit.start <= range.end && range.start <= edit.old_end
|
||||||
|
}
|
||||||
|
|
||||||
fn translate_inline_adornments(adornments: &mut [InlineAdornment], edit: TextProjectionEdit) {
|
fn translate_inline_adornments(adornments: &mut [InlineAdornment], edit: TextProjectionEdit) {
|
||||||
for adornment in adornments {
|
for adornment in adornments {
|
||||||
adornment.at = translate_byte_position(adornment.at, edit);
|
adornment.at = translate_byte_position(adornment.at, edit);
|
||||||
|
|
@ -4351,6 +4533,48 @@ fn push_quad_vertex(bytes: &mut Vec<u8>, x: f32, y: f32, color: [f32; 4]) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vertex bytes for the diagnostic-squiggle pipeline (Q#W1). Same six
|
||||||
|
/// vertices per rect as a quad, but each carries a `uv`: `uv.x` is the
|
||||||
|
/// absolute screen-space pixel x of the corner (continuous phase
|
||||||
|
/// across separately emitted rects), `uv.y` is the signed pixel offset
|
||||||
|
/// from the band's vertical centerline (`±h/2`). The fragment shader
|
||||||
|
/// turns that into the sine.
|
||||||
|
fn squiggles_to_vertex_bytes(rects: &[MinimapRect], width: u32, height: u32) -> Vec<u8> {
|
||||||
|
let mut bytes = Vec::with_capacity(rects.len() * 6 * SQUIGGLE_VERTEX_STRIDE as usize);
|
||||||
|
for rect in rects {
|
||||||
|
if rect.w <= 0.0 || rect.h <= 0.0 || width == 0 || height == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let x0 = px_to_ndc_x(rect.x, width);
|
||||||
|
let x1 = px_to_ndc_x(rect.x + rect.w, width);
|
||||||
|
let y0 = px_to_ndc_y(rect.y, height);
|
||||||
|
let y1 = px_to_ndc_y(rect.y + rect.h, height);
|
||||||
|
let (left, right) = (rect.x, rect.x + rect.w);
|
||||||
|
let half = rect.h * 0.5;
|
||||||
|
// Top edge → centerline-relative uv.y = -half; bottom → +half.
|
||||||
|
push_squiggle_vertex(&mut bytes, x0, y0, left, -half, rect.color);
|
||||||
|
push_squiggle_vertex(&mut bytes, x1, y0, right, -half, rect.color);
|
||||||
|
push_squiggle_vertex(&mut bytes, x1, y1, right, half, rect.color);
|
||||||
|
push_squiggle_vertex(&mut bytes, x0, y0, left, -half, rect.color);
|
||||||
|
push_squiggle_vertex(&mut bytes, x1, y1, right, half, rect.color);
|
||||||
|
push_squiggle_vertex(&mut bytes, x0, y1, left, half, rect.color);
|
||||||
|
}
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_squiggle_vertex(
|
||||||
|
bytes: &mut Vec<u8>,
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
uv_x: f32,
|
||||||
|
uv_y: f32,
|
||||||
|
color: [f32; 4],
|
||||||
|
) {
|
||||||
|
for value in [x, y, uv_x, uv_y, color[0], color[1], color[2], color[3]] {
|
||||||
|
bytes.extend_from_slice(&value.to_ne_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn px_to_ndc_x(x: f32, width: u32) -> f32 {
|
fn px_to_ndc_x(x: f32, width: u32) -> f32 {
|
||||||
x / width as f32 * 2.0 - 1.0
|
x / width as f32 * 2.0 - 1.0
|
||||||
}
|
}
|
||||||
|
|
@ -4587,22 +4811,23 @@ fn indexed_to_glyphon(idx: u8) -> glyphon::Color {
|
||||||
glyphon::Color::rgb(level, level, level)
|
glyphon::Color::rgb(level, level, level)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Height of the diagnostic underline bar, in pixels. Straight-bar
|
/// Height of the diagnostic squiggle band, in pixels (Q#W1). Taller
|
||||||
/// v1; a wavy squiggle needs shader/texture work and waits until the
|
/// than the original straight bar (2px) so the sine wave fits: the
|
||||||
/// straight bar is proven (framing Q#D1).
|
/// fragment shader's `amplitude + thickness` (~2.4px each side) must
|
||||||
const DIAG_UNDERLINE_PX: f32 = 2.0;
|
/// sit inside half the band.
|
||||||
|
const DIAG_SQUIGGLE_PX: f32 = 6.0;
|
||||||
|
|
||||||
/// Map a [`DecorationKind`] to an underline-bar color, or `None` for
|
/// Map a [`DecorationKind`] to an underline color, or `None` for
|
||||||
/// kinds that don't underline.
|
/// kinds that don't underline.
|
||||||
///
|
///
|
||||||
/// Session 5 originally rendered diagnostics by *recoloring the text
|
/// Session 5 originally rendered diagnostics by *recoloring the text
|
||||||
/// foreground*, which clobbered the syntax color of the very token
|
/// foreground*, which clobbered the syntax color of the very token
|
||||||
/// the diagnostic points at — the same flaw the TUI fixed with
|
/// the diagnostic points at — the same flaw the TUI fixed with
|
||||||
/// protocol v6's `underline_color` (T M4.6). The GPU's equivalent is
|
/// protocol v6's `underline_color` (T M4.6). The GPU's equivalent is
|
||||||
/// a [`DIAG_UNDERLINE_PX`]-tall quad hugging the bottom of the glyph
|
/// a wavy squiggle hugging the bottom of the glyph extent (Q#W1, was
|
||||||
/// extent; the text keeps its syntax color. Same RGB palette the fg
|
/// a straight bar through PR #65); the text keeps its syntax color.
|
||||||
/// path used (red / yellow / light blue / dim gray), so the window's
|
/// Same RGB palette the fg path used (red / yellow / light blue /
|
||||||
/// severity language is unchanged.
|
/// dim gray), so the window's severity language is unchanged.
|
||||||
fn decoration_kind_to_underline_color(kind: DecorationKind) -> Option<[f32; 4]> {
|
fn decoration_kind_to_underline_color(kind: DecorationKind) -> Option<[f32; 4]> {
|
||||||
match kind {
|
match kind {
|
||||||
// ANSI bright red — matches TUI diagnostic-error palette.
|
// ANSI bright red — matches TUI diagnostic-error palette.
|
||||||
|
|
@ -5292,6 +5517,45 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editing_a_diagnostic_clears_it_optimistically_but_holds_untouched_ones() {
|
||||||
|
let diag = |start, end| Decoration {
|
||||||
|
range: ByteRange { start, end },
|
||||||
|
kind: DecorationKind::DiagnosticError,
|
||||||
|
};
|
||||||
|
// Insert the missing char right at the 1-byte widened anchor of
|
||||||
|
// an end-of-line "expected COMMA": the squiggle clears now.
|
||||||
|
let mut decos = vec![diag(11, 12), diag(40, 50)];
|
||||||
|
translate_decorations(
|
||||||
|
&mut decos,
|
||||||
|
TextProjectionEdit {
|
||||||
|
start: 11,
|
||||||
|
old_end: 11,
|
||||||
|
inserted_len: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// The touched diagnostic is gone; the far one is kept (shifted
|
||||||
|
// right by the insert) — hold-while-stale still applies to it.
|
||||||
|
assert_eq!(decos.len(), 1, "only the touched diagnostic clears");
|
||||||
|
assert_eq!(decos[0].range, ByteRange { start: 41, end: 51 });
|
||||||
|
|
||||||
|
// A non-diagnostic decoration over the edited region is never
|
||||||
|
// dropped — selection / current-line translate as before.
|
||||||
|
let mut sel = vec![Decoration {
|
||||||
|
range: ByteRange { start: 8, end: 14 },
|
||||||
|
kind: DecorationKind::Selection,
|
||||||
|
}];
|
||||||
|
translate_decorations(
|
||||||
|
&mut sel,
|
||||||
|
TextProjectionEdit {
|
||||||
|
start: 10,
|
||||||
|
old_end: 10,
|
||||||
|
inserted_len: 2,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(sel.len(), 1, "selection survives an edit in its range");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn source_line_range_handles_empty_and_leading_newline() {
|
fn source_line_range_handles_empty_and_leading_newline() {
|
||||||
assert_eq!(source_line_range("", 0), (0, 0));
|
assert_eq!(source_line_range("", 0), (0, 0));
|
||||||
|
|
@ -5646,4 +5910,44 @@ mod tests {
|
||||||
assert!((f32_at(&bytes, 1) - 1.0).abs() < 0.001);
|
assert!((f32_at(&bytes, 1) - 1.0).abs() < 0.001);
|
||||||
assert!((f32_at(&bytes, 2) - 1.0).abs() < 0.001);
|
assert!((f32_at(&bytes, 2) - 1.0).abs() < 0.001);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn squiggle_vertices_carry_centerline_relative_uv() {
|
||||||
|
// Q#W1: uv.x is the absolute screen pixel x (so phase is
|
||||||
|
// continuous across rects); uv.y is signed px from the band
|
||||||
|
// centerline (±h/2). Layout per vertex: [ndc_x, ndc_y, uv_x,
|
||||||
|
// uv_y, r, g, b, a] — 8 floats.
|
||||||
|
let rect = MinimapRect {
|
||||||
|
x: 10.0,
|
||||||
|
y: 20.0,
|
||||||
|
w: 30.0,
|
||||||
|
h: 6.0,
|
||||||
|
color: [0.9, 0.1, 0.2, 1.0],
|
||||||
|
};
|
||||||
|
let bytes = squiggles_to_vertex_bytes(&[rect], 100, 100);
|
||||||
|
assert_eq!(bytes.len(), 6 * SQUIGGLE_VERTEX_STRIDE as usize);
|
||||||
|
|
||||||
|
// Vertex 0 = top-left: uv = (left x, -h/2).
|
||||||
|
assert!((f32_at(&bytes, 2) - 10.0).abs() < 0.001, "uv.x left");
|
||||||
|
assert!((f32_at(&bytes, 3) + 3.0).abs() < 0.001, "uv.y top = -h/2");
|
||||||
|
// Color rides every vertex.
|
||||||
|
assert!((f32_at(&bytes, 4) - 0.9).abs() < 0.001);
|
||||||
|
assert!((f32_at(&bytes, 7) - 1.0).abs() < 0.001);
|
||||||
|
// Vertex 2 = bottom-right (8 floats in): uv = (right x, +h/2).
|
||||||
|
assert!((f32_at(&bytes, 16 + 2) - 40.0).abs() < 0.001, "uv.x right");
|
||||||
|
assert!((f32_at(&bytes, 16 + 3) - 3.0).abs() < 0.001, "uv.y bottom");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn squiggles_skip_degenerate_rects() {
|
||||||
|
let zero_w = MinimapRect {
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
w: 0.0,
|
||||||
|
h: 6.0,
|
||||||
|
color: [1.0, 0.0, 0.0, 1.0],
|
||||||
|
};
|
||||||
|
assert!(squiggles_to_vertex_bytes(&[zero_w], 100, 100).is_empty());
|
||||||
|
assert!(squiggles_to_vertex_bytes(&[zero_w], 0, 100).is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue