Merge pull request #67 from levineuwirth/session-gpu-status-band
GPU status band: local L:C/scroll + StatusFacts (protocol v8)
This commit is contained in:
commit
d1ca124b33
|
|
@ -0,0 +1,80 @@
|
|||
# pmacs-gpu status band — framing pass
|
||||
|
||||
Date: 2026-06-12. The GPU window has no status surface: no buffer
|
||||
name, no modified flag, no cursor position, no diagnostic counts
|
||||
(the M4.6 parity session deferred counts here for exactly this
|
||||
reason). Survey facts: `InstanceMessage::ModeLine(Vec<Cell>)` has
|
||||
sat reserved-but-unused since day one; `BufferSnapshot` carries no
|
||||
name/modified; glyphon's `prepare()` takes `&[TextArea]` so a second
|
||||
band area is structurally cheap; five geometry sites assume text
|
||||
runs to the surface bottom (`estimated_visible_lines`,
|
||||
`TextBounds.bottom`, the minimap height pair, the edge-scroll
|
||||
bottom band).
|
||||
|
||||
## Q#S1 — where status facts come from
|
||||
|
||||
**Stance: split by authority and freshness.**
|
||||
|
||||
- **Locally derived, per frame**: cursor L:C (from `own_cursor` ×
|
||||
`current_line_starts` — the *optimistic* caret, so the readout
|
||||
tracks typing bursts instead of lagging a round trip behind
|
||||
them), and the scroll indicator (`scroll_top` × visible ×
|
||||
total, the TUI's All/Top/Bot/NN% formula).
|
||||
- **Instance-authoritative, on the wire**: buffer name, modified
|
||||
flag, exact whole-file diagnostic counts. A new additive variant
|
||||
(protocol v8, ladder continues on the v6 floor):
|
||||
`StatusFacts { buffer_id, name, modified, diag_errors,
|
||||
diag_warnings }`, emitted by the semantic producer when any fact
|
||||
changes (cached-compare, like `FileStyleSummary`; counts piggyback
|
||||
the diag-store epoch from the parity session).
|
||||
|
||||
Rejected: populating the reserved `ModeLine(Vec<Cell>)`. It is
|
||||
grid-shaped — pre-formatted styled cells — which bakes the TUI's
|
||||
layout into a frontend that does its own, and a daemon-formatted
|
||||
L:C would visibly lag the optimistic caret. The variant stays
|
||||
reserved for grid use. Also rejected: counting marked lines in
|
||||
`FileStyleSummary` as the counts — that counts *lines*, the TUI
|
||||
counts *diagnostics*; two frontends showing different numbers for
|
||||
the same buffer reads as a bug.
|
||||
|
||||
## Q#S2 — band rendering
|
||||
|
||||
**Stance: quad + second `TextArea`.** A `STATUS_BAND_HEIGHT` (26px)
|
||||
strip at the surface bottom: a background quad through the existing
|
||||
quad pipeline, and a one-line cosmic-text `Buffer` shaped only when
|
||||
the composed status string changes, passed as a second `TextArea`
|
||||
in the same `prepare()` call. Left: `name *` (modified star).
|
||||
Right: `E:n W:n L:C NN%` — diag counts colored by the severity
|
||||
palette, omitted when zero.
|
||||
|
||||
## Q#S3 — geometry
|
||||
|
||||
**Stance: one helper, no scattered arithmetic.** `text_area_bottom
|
||||
(surface_height) = height - STATUS_BAND_HEIGHT` feeds every site
|
||||
that assumed the surface bottom: `estimated_visible_lines`,
|
||||
`TextBounds.bottom`, the minimap height pair
|
||||
(`minimap_band_contains` / `minimap_y_to_line` / painters), and the
|
||||
edge-scroll bottom band. The minimap keeps its own `MINIMAP_BOTTOM`
|
||||
inset *above* the band.
|
||||
|
||||
## Predicted findings (categorical bets)
|
||||
|
||||
1. **A missed bottom-assuming site**: something still measures to
|
||||
the raw surface bottom — surfaces as content drawn under the
|
||||
band, or a hit-test/scroll off-by-a-line at the window bottom.
|
||||
2. **Visible-lines ripple**: shrinking `estimated_visible_lines`
|
||||
perturbs viewport/scroll math tuned to the old height — surfaces
|
||||
as a flickering scroll indicator or an unreachable last line.
|
||||
3. **Optimistic L:C flicker**: the locally derived readout jumps
|
||||
when a daemon `CursorByte` confirms through the floor-release
|
||||
path mid-burst — cosmetic, but noticeable enough to get reported.
|
||||
|
||||
## Session plan
|
||||
|
||||
Order: geometry + band rendering with the local facts (L:C, scroll)
|
||||
→ v8 `StatusFacts` wire + daemon emission + name/modified/counts →
|
||||
tests/polish. Manual validation: resize the window, type a burst
|
||||
mid-file (L:C tracks), scroll to both extremes (Top/Bot), introduce
|
||||
and fix an error (counts appear/vanish), edit (modified star), and
|
||||
re-run the minimap + edge auto-scroll gestures against the new
|
||||
bottom edge.
|
||||
|
|
@ -101,6 +101,14 @@ const EDGE_SCROLL_TICK: std::time::Duration = std::time::Duration::from_millis(3
|
|||
/// flash. Short enough to read as instantaneous when styling never
|
||||
/// arrives (plain-text buffers).
|
||||
const JUMP_STYLE_HOLD: std::time::Duration = std::time::Duration::from_millis(25);
|
||||
/// Status band (Q#S2): one-line strip reserved at the surface
|
||||
/// bottom — buffer name + modified star on the left, diagnostics /
|
||||
/// cursor / scroll readout on the right.
|
||||
const STATUS_BAND_HEIGHT: f32 = 26.0;
|
||||
const STATUS_BAND_BG: [f32; 4] = [0.105, 0.105, 0.145, 1.0];
|
||||
const STATUS_TEXT_PAD: f32 = 10.0;
|
||||
const STATUS_FONT_SIZE: f32 = 13.0;
|
||||
const STATUS_LINE_HEIGHT: f32 = 18.0;
|
||||
const QUAD_SHADER: &str = r"
|
||||
struct VertexOut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
|
|
@ -459,11 +467,37 @@ struct State {
|
|||
bg_vertex_buffer: ReusableVertexBuffer,
|
||||
caret_vertex_buffer: ReusableVertexBuffer,
|
||||
minimap_vertex_buffer: ReusableVertexBuffer,
|
||||
/// Q#S2 — the status band's one-line text. Shaped only when the
|
||||
/// composed status string changes; rendered as a second
|
||||
/// `TextArea` in the same prepare pass as the main buffer.
|
||||
status_buffer: Buffer,
|
||||
/// The string `status_buffer` currently holds, for change
|
||||
/// detection.
|
||||
status_text: String,
|
||||
/// Q#S2 — the band's left side (buffer name + modified dot),
|
||||
/// its own buffer so it left-aligns independently of the
|
||||
/// right-aligned readout.
|
||||
status_left_buffer: Buffer,
|
||||
/// Change-detection twin of `status_text` for the left side.
|
||||
status_left_text: String,
|
||||
/// Q#S1 — the wire-authoritative status facts (protocol v8).
|
||||
status_facts: Option<StatusFactsLocal>,
|
||||
/// Minimap vertex bytes cached by [`MinimapCacheKey`] —
|
||||
/// rebuilding rescanned every line shape per frame.
|
||||
minimap_cache: Option<(MinimapCacheKey, Vec<u8>)>,
|
||||
}
|
||||
|
||||
/// The wire-authoritative status facts (Q#S1, protocol v8),
|
||||
/// mirrored from `InstanceMessage::StatusFacts`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct StatusFactsLocal {
|
||||
buffer_id: BufferId,
|
||||
name: String,
|
||||
modified: bool,
|
||||
diag_errors: u32,
|
||||
diag_warnings: u32,
|
||||
}
|
||||
|
||||
/// pmacs-gpu's own cursor position, mirrored from `CursorByte`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct OwnCursor {
|
||||
|
|
@ -1184,6 +1218,24 @@ impl State {
|
|||
Some(config.width as f32),
|
||||
Some(config.height as f32),
|
||||
);
|
||||
let mut status_buffer = Buffer::new(
|
||||
&mut font_system,
|
||||
Metrics::new(STATUS_FONT_SIZE, STATUS_LINE_HEIGHT),
|
||||
);
|
||||
status_buffer.set_size(
|
||||
&mut font_system,
|
||||
Some(config.width as f32),
|
||||
Some(STATUS_BAND_HEIGHT),
|
||||
);
|
||||
let mut status_left_buffer = Buffer::new(
|
||||
&mut font_system,
|
||||
Metrics::new(STATUS_FONT_SIZE, STATUS_LINE_HEIGHT),
|
||||
);
|
||||
status_left_buffer.set_size(
|
||||
&mut font_system,
|
||||
Some(config.width as f32),
|
||||
Some(STATUS_BAND_HEIGHT),
|
||||
);
|
||||
buffer.set_text(
|
||||
&mut font_system,
|
||||
initial_text,
|
||||
|
|
@ -1247,6 +1299,11 @@ impl State {
|
|||
bg_vertex_buffer: ReusableVertexBuffer::new(),
|
||||
caret_vertex_buffer: ReusableVertexBuffer::new(),
|
||||
minimap_vertex_buffer: ReusableVertexBuffer::new(),
|
||||
status_buffer,
|
||||
status_text: String::new(),
|
||||
status_left_buffer,
|
||||
status_left_text: String::new(),
|
||||
status_facts: None,
|
||||
minimap_cache: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -1821,6 +1878,25 @@ impl State {
|
|||
self.apply_file_style_summary(buffer_id, generation, lines);
|
||||
None
|
||||
}
|
||||
// Q#S1 (protocol v8) — the wire-authoritative half of the
|
||||
// status band: name, modified, whole-file diag counts.
|
||||
InstanceMessage::StatusFacts {
|
||||
buffer_id,
|
||||
name,
|
||||
modified,
|
||||
diag_errors,
|
||||
diag_warnings,
|
||||
} => {
|
||||
self.status_facts = Some(StatusFactsLocal {
|
||||
buffer_id,
|
||||
name,
|
||||
modified,
|
||||
diag_errors,
|
||||
diag_warnings,
|
||||
});
|
||||
self.window.request_redraw();
|
||||
None
|
||||
}
|
||||
// Session 9.3 — peer presence. The editing frontend's
|
||||
// cursor + selection drive the `CurrentLine` / `Selection`
|
||||
// washes for this read-only mirror (finding QB1). Store
|
||||
|
|
@ -2264,6 +2340,139 @@ impl State {
|
|||
self.window.request_redraw();
|
||||
}
|
||||
|
||||
/// Compose the status-band readout (Q#S1): diagnostic counts
|
||||
/// (wire-authoritative, severity-colored, omitted when zero),
|
||||
/// then cursor L:C from the *optimistic* caret (so it tracks
|
||||
/// typing bursts instead of lagging a round trip), then the
|
||||
/// All/Top/Bot/NN% scroll indicator. Returns the colored spans.
|
||||
fn compose_status_spans(&self) -> Vec<(String, Option<Color>)> {
|
||||
let mut spans: Vec<(String, Option<Color>)> = Vec::new();
|
||||
if let Some(facts) = self
|
||||
.status_facts
|
||||
.as_ref()
|
||||
.filter(|f| Some(f.buffer_id) == self.current_buffer_id)
|
||||
{
|
||||
if facts.diag_errors > 0 {
|
||||
spans.push((
|
||||
format!("E:{}", facts.diag_errors),
|
||||
Some(Color::rgb(241, 76, 76)),
|
||||
));
|
||||
}
|
||||
if facts.diag_warnings > 0 {
|
||||
spans.push((
|
||||
format!("W:{}", facts.diag_warnings),
|
||||
Some(Color::rgb(245, 245, 67)),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut readout = String::new();
|
||||
let mut cursor_row = self.scroll_top;
|
||||
if let Some(own) = self.own_cursor
|
||||
&& self.current_buffer_id == Some(own.buffer_id)
|
||||
{
|
||||
let byte = floor_char_boundary(
|
||||
&self.current_text,
|
||||
(own.byte as usize).min(self.current_text.len()),
|
||||
);
|
||||
let line = self
|
||||
.current_line_starts
|
||||
.partition_point(|&s| s as usize <= byte)
|
||||
.saturating_sub(1);
|
||||
cursor_row = line;
|
||||
let ls = self.current_line_starts.get(line).copied().unwrap_or(0) as usize;
|
||||
let col = self
|
||||
.current_text
|
||||
.get(ls..byte)
|
||||
.map_or(0, |s| s.chars().count());
|
||||
readout.push_str(&format!("L{}:C{}", line + 1, col + 1));
|
||||
readout.push_str(" ");
|
||||
}
|
||||
readout.push_str(&format_scroll_indicator(
|
||||
self.scroll_top,
|
||||
estimated_visible_lines(self.config.height),
|
||||
self.current_line_starts.len(),
|
||||
cursor_row,
|
||||
));
|
||||
spans.push((readout, None));
|
||||
spans
|
||||
}
|
||||
|
||||
/// The band's left side: buffer name + modified dot, from the
|
||||
/// v8 `StatusFacts` (empty until the daemon ships them).
|
||||
fn compose_status_left(&self) -> String {
|
||||
match self
|
||||
.status_facts
|
||||
.as_ref()
|
||||
.filter(|f| Some(f.buffer_id) == self.current_buffer_id)
|
||||
{
|
||||
Some(facts) if facts.modified => format!("{} ●", facts.name),
|
||||
Some(facts) => facts.name.clone(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-shape the status-band text iff the composed content
|
||||
/// changed (short lines — shaping is trivial, but not free per
|
||||
/// frame).
|
||||
fn refresh_status_line(&mut self) {
|
||||
let spans = self.compose_status_spans();
|
||||
let composed: String = spans
|
||||
.iter()
|
||||
.map(|(t, _)| t.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono"));
|
||||
if composed != self.status_text {
|
||||
let mut rich: Vec<(&str, Attrs)> = Vec::new();
|
||||
for (i, (t, c)) in spans.iter().enumerate() {
|
||||
if i > 0 {
|
||||
rich.push((" ", default_attrs.clone()));
|
||||
}
|
||||
let attrs = match c {
|
||||
Some(color) => default_attrs.clone().color(*color),
|
||||
None => default_attrs.clone(),
|
||||
};
|
||||
rich.push((t.as_str(), attrs));
|
||||
}
|
||||
self.status_buffer.set_rich_text(
|
||||
&mut self.font_system,
|
||||
rich,
|
||||
&default_attrs,
|
||||
Shaping::Advanced,
|
||||
None,
|
||||
);
|
||||
self.status_buffer
|
||||
.shape_until_scroll(&mut self.font_system, false);
|
||||
self.status_text = composed;
|
||||
}
|
||||
let left = self.compose_status_left();
|
||||
if left != self.status_left_text {
|
||||
self.status_left_buffer.set_text(
|
||||
&mut self.font_system,
|
||||
&left,
|
||||
&default_attrs,
|
||||
Shaping::Advanced,
|
||||
None,
|
||||
);
|
||||
self.status_left_buffer
|
||||
.shape_until_scroll(&mut self.font_system, false);
|
||||
self.status_left_text = left;
|
||||
}
|
||||
}
|
||||
|
||||
/// The status band's background quad (Q#S2): a full-width strip
|
||||
/// under the band text.
|
||||
fn status_band_vertex_bytes(&self) -> Vec<u8> {
|
||||
let rect = MinimapRect {
|
||||
x: 0.0,
|
||||
y: text_area_bottom(self.config.height),
|
||||
w: self.config.width as f32,
|
||||
h: STATUS_BAND_HEIGHT,
|
||||
color: STATUS_BAND_BG,
|
||||
};
|
||||
rects_to_vertex_bytes(&[rect], self.config.width, self.config.height)
|
||||
}
|
||||
|
||||
/// Bookkeeping for an outgoing Pointer event: it supersedes any
|
||||
/// unconfirmed optimistic-cursor prediction (the daemon's answer
|
||||
/// will be the click position, not the typing prediction), and
|
||||
|
|
@ -2552,6 +2761,16 @@ impl State {
|
|||
Some(width as f32),
|
||||
Some(height as f32),
|
||||
);
|
||||
self.status_buffer.set_size(
|
||||
&mut self.font_system,
|
||||
Some(width as f32),
|
||||
Some(STATUS_BAND_HEIGHT),
|
||||
);
|
||||
self.status_left_buffer.set_size(
|
||||
&mut self.font_system,
|
||||
Some(width as f32),
|
||||
Some(STATUS_BAND_HEIGHT),
|
||||
);
|
||||
// A taller/shorter window changes the visible line count, so the
|
||||
// slice + scoped viewport change (session S1).
|
||||
self.reshape();
|
||||
|
|
@ -2579,7 +2798,11 @@ impl State {
|
|||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let frame_start = debug_frame().then(std::time::Instant::now);
|
||||
let bg_vertices = self.decoration_background_vertex_bytes();
|
||||
self.refresh_status_line();
|
||||
// The band's strip rides the bg quad batch so it draws under
|
||||
// the band text (text renders after the first quad draw).
|
||||
let mut bg_vertices = self.decoration_background_vertex_bytes();
|
||||
bg_vertices.extend(self.status_band_vertex_bytes());
|
||||
let bg_vertex_count = (bg_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32;
|
||||
let bg_buffer = self
|
||||
.bg_vertex_buffer
|
||||
|
|
@ -2632,6 +2855,17 @@ impl State {
|
|||
let after_minimap = debug_frame().then(std::time::Instant::now);
|
||||
let text_bounds_right = self.text_bounds_right();
|
||||
|
||||
// Right-align the status readout: measure the shaped width
|
||||
// and place the area flush to the right pad (Q#S2).
|
||||
let status_width = self
|
||||
.status_buffer
|
||||
.layout_runs()
|
||||
.map(|r| r.line_w)
|
||||
.fold(0.0_f32, f32::max);
|
||||
let status_left =
|
||||
(self.config.width as f32 - STATUS_TEXT_PAD - status_width).max(TEXT_LEFT);
|
||||
let status_top =
|
||||
text_area_bottom(self.config.height) + (STATUS_BAND_HEIGHT - STATUS_LINE_HEIGHT) / 2.0;
|
||||
self.text_renderer
|
||||
.prepare(
|
||||
&self.device,
|
||||
|
|
@ -2639,20 +2873,54 @@ impl State {
|
|||
&mut self.font_system,
|
||||
&mut self.atlas,
|
||||
&self.viewport,
|
||||
[TextArea {
|
||||
buffer: &self.buffer,
|
||||
left: TEXT_LEFT,
|
||||
top: TEXT_TOP,
|
||||
scale: 1.0,
|
||||
bounds: TextBounds {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: text_bounds_right,
|
||||
bottom: self.config.height.cast_signed(),
|
||||
[
|
||||
TextArea {
|
||||
buffer: &self.buffer,
|
||||
left: TEXT_LEFT,
|
||||
top: TEXT_TOP,
|
||||
scale: 1.0,
|
||||
bounds: TextBounds {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: text_bounds_right,
|
||||
// Clip at the status band (Q#S3): a final
|
||||
// partially-visible line must not bleed
|
||||
// into the band.
|
||||
bottom: text_area_bottom(self.config.height).round() as i32,
|
||||
},
|
||||
default_color: Color::rgb(230, 230, 235),
|
||||
custom_glyphs: &[],
|
||||
},
|
||||
default_color: Color::rgb(230, 230, 235),
|
||||
custom_glyphs: &[],
|
||||
}],
|
||||
TextArea {
|
||||
buffer: &self.status_buffer,
|
||||
left: status_left,
|
||||
top: status_top,
|
||||
scale: 1.0,
|
||||
bounds: TextBounds {
|
||||
left: 0,
|
||||
top: text_area_bottom(self.config.height).round() as i32,
|
||||
right: self.config.width.cast_signed(),
|
||||
bottom: self.config.height.cast_signed(),
|
||||
},
|
||||
default_color: Color::rgb(168, 168, 180),
|
||||
custom_glyphs: &[],
|
||||
},
|
||||
TextArea {
|
||||
buffer: &self.status_left_buffer,
|
||||
left: STATUS_TEXT_PAD,
|
||||
top: status_top,
|
||||
scale: 1.0,
|
||||
bounds: TextBounds {
|
||||
left: 0,
|
||||
top: text_area_bottom(self.config.height).round() as i32,
|
||||
// Stop before the right-aligned readout.
|
||||
right: (status_left - STATUS_TEXT_PAD).max(0.0).round() as i32,
|
||||
bottom: self.config.height.cast_signed(),
|
||||
},
|
||||
default_color: Color::rgb(200, 200, 210),
|
||||
custom_glyphs: &[],
|
||||
},
|
||||
],
|
||||
&mut self.swash_cache,
|
||||
)
|
||||
.expect("text_renderer prepare");
|
||||
|
|
@ -3074,8 +3342,20 @@ fn minimap_left(surface_width: u32) -> Option<f32> {
|
|||
(x > TEXT_LEFT + TEXT_RIGHT_GAP).then_some(x)
|
||||
}
|
||||
|
||||
/// Where editor content stops and the status band begins (Q#S3) —
|
||||
/// the single source for every bottom-of-text computation.
|
||||
fn text_area_bottom(surface_height: u32) -> f32 {
|
||||
(surface_height as f32 - STATUS_BAND_HEIGHT).max(0.0)
|
||||
}
|
||||
|
||||
/// The minimap's drawable height: the text area minus its own
|
||||
/// top/bottom insets.
|
||||
fn minimap_height(surface_height: u32) -> f32 {
|
||||
text_area_bottom(surface_height) - MINIMAP_TOP - MINIMAP_BOTTOM
|
||||
}
|
||||
|
||||
fn estimated_visible_lines(surface_height: u32) -> usize {
|
||||
((surface_height as f32 - TEXT_TOP.max(0.0)) / CODE_LINE_HEIGHT)
|
||||
((text_area_bottom(surface_height) - TEXT_TOP.max(0.0)) / CODE_LINE_HEIGHT)
|
||||
.ceil()
|
||||
.max(1.0) as usize
|
||||
}
|
||||
|
|
@ -3087,7 +3367,7 @@ fn minimap_band_contains(x: f32, y: f32, surface_width: u32, surface_height: u32
|
|||
let Some(left) = minimap_left(surface_width) else {
|
||||
return false;
|
||||
};
|
||||
let height = surface_height as f32 - MINIMAP_TOP - MINIMAP_BOTTOM;
|
||||
let height = minimap_height(surface_height);
|
||||
height > 0.0
|
||||
&& x >= left
|
||||
&& x < surface_width as f32 - MINIMAP_RIGHT
|
||||
|
|
@ -3095,13 +3375,41 @@ fn minimap_band_contains(x: f32, y: f32, surface_width: u32, surface_height: u32
|
|||
&& y < MINIMAP_TOP + height
|
||||
}
|
||||
|
||||
/// The TUI mode line's scroll readout, ported verbatim (Q#S1): "All"
|
||||
/// when the buffer fits, "Top"/"Bot" at the extremes, else the cursor
|
||||
/// row as a percentage of the file.
|
||||
fn format_scroll_indicator(
|
||||
view_top: usize,
|
||||
visible: usize,
|
||||
total_lines: usize,
|
||||
cursor_row: usize,
|
||||
) -> String {
|
||||
if total_lines <= 1 {
|
||||
return "All".to_string();
|
||||
}
|
||||
if visible > 0 {
|
||||
if visible >= total_lines {
|
||||
return "All".to_string();
|
||||
}
|
||||
if view_top == 0 {
|
||||
return "Top".to_string();
|
||||
}
|
||||
if view_top.saturating_add(visible) >= total_lines {
|
||||
return "Bot".to_string();
|
||||
}
|
||||
}
|
||||
let pct = (cursor_row + 1).saturating_mul(100) / total_lines;
|
||||
format!("{pct}%")
|
||||
}
|
||||
|
||||
/// Q#M7 — which way (if any) a drag at pixel `y` should auto-scroll:
|
||||
/// `-1` in the band hugging the text area's top, `+1` in the band at
|
||||
/// the surface bottom, `None` in the interior.
|
||||
/// the text area's bottom (above the status band), `None` in the
|
||||
/// interior.
|
||||
fn edge_scroll_direction(y: f32, surface_height: u32) -> Option<i64> {
|
||||
if y < TEXT_TOP + EDGE_SCROLL_BAND {
|
||||
Some(-1)
|
||||
} else if y > surface_height as f32 - EDGE_SCROLL_BAND {
|
||||
} else if y > text_area_bottom(surface_height) - EDGE_SCROLL_BAND {
|
||||
Some(1)
|
||||
} else {
|
||||
None
|
||||
|
|
@ -3116,7 +3424,7 @@ fn minimap_y_to_line(y: f32, surface_height: u32, total_lines: usize) -> Option<
|
|||
if total_lines == 0 {
|
||||
return None;
|
||||
}
|
||||
let height = surface_height as f32 - MINIMAP_TOP - MINIMAP_BOTTOM;
|
||||
let height = minimap_height(surface_height);
|
||||
if height <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -3135,10 +3443,10 @@ fn minimap_rects(
|
|||
let Some(x) = minimap_left(surface_width) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if lines.is_empty() || surface_height as f32 <= MINIMAP_TOP + MINIMAP_BOTTOM {
|
||||
if lines.is_empty() || minimap_height(surface_height) <= 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let height = surface_height as f32 - MINIMAP_TOP - MINIMAP_BOTTOM;
|
||||
let height = minimap_height(surface_height);
|
||||
let pixel_rows = height.round().max(1.0) as usize;
|
||||
let mut rects = Vec::new();
|
||||
rects.push(MinimapRect {
|
||||
|
|
@ -3415,6 +3723,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
|
|||
InstanceMessage::Decorations { .. } => "Decorations",
|
||||
InstanceMessage::InlineAdornments { .. } => "InlineAdornments",
|
||||
InstanceMessage::FileStyleSummary { .. } => "FileStyleSummary",
|
||||
InstanceMessage::StatusFacts { .. } => "StatusFacts",
|
||||
InstanceMessage::BlockAdornments { .. } => "BlockAdornments",
|
||||
InstanceMessage::FoldState { .. } => "FoldState",
|
||||
InstanceMessage::ResourceOffer { .. } => "ResourceOffer",
|
||||
|
|
@ -5151,8 +5460,10 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn minimap_band_and_inverse_line_mapping() {
|
||||
// 800×600 surface: band x = [800-12-48, 800-12) = [740, 788),
|
||||
// y = [12, 588).
|
||||
// 800×600 surface: band x = [800-12-48, 800-12) = [740, 788).
|
||||
// The status band reserves 26px (Q#S3), so the text area
|
||||
// ends at 574 and the minimap column is y = [12, 562)
|
||||
// (height 550).
|
||||
assert!(minimap_band_contains(750.0, 100.0, 800, 600));
|
||||
assert!(
|
||||
!minimap_band_contains(739.0, 100.0, 800, 600),
|
||||
|
|
@ -5163,15 +5474,18 @@ mod tests {
|
|||
"right of band"
|
||||
);
|
||||
assert!(!minimap_band_contains(750.0, 5.0, 800, 600), "above band");
|
||||
assert!(!minimap_band_contains(750.0, 590.0, 800, 600), "below band");
|
||||
assert!(
|
||||
!minimap_band_contains(750.0, 563.0, 800, 600),
|
||||
"below band (status strip)"
|
||||
);
|
||||
// Too-narrow surfaces have no minimap at all.
|
||||
assert!(!minimap_band_contains(100.0, 100.0, 150, 600));
|
||||
|
||||
// Inverse mapping: height = 576; 100 lines. Top → line 0,
|
||||
// Inverse mapping: height = 550; 100 lines. Top → line 0,
|
||||
// bottom → last line, midpoint → ~half.
|
||||
assert_eq!(minimap_y_to_line(12.0, 600, 100), Some(0));
|
||||
assert_eq!(minimap_y_to_line(587.9, 600, 100), Some(99));
|
||||
assert_eq!(minimap_y_to_line(12.0 + 288.0, 600, 100), Some(50));
|
||||
assert_eq!(minimap_y_to_line(561.9, 600, 100), Some(99));
|
||||
assert_eq!(minimap_y_to_line(12.0 + 275.0, 600, 100), Some(50));
|
||||
// Out-of-band y clamps rather than panics (scrubbing wanders).
|
||||
assert_eq!(minimap_y_to_line(0.0, 600, 100), Some(0));
|
||||
assert_eq!(minimap_y_to_line(9999.0, 600, 100), Some(99));
|
||||
|
|
@ -5180,17 +5494,30 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn edge_scroll_direction_bands() {
|
||||
// 600px surface: up-band y < 16 + 24 = 40; down-band y > 576.
|
||||
// 600px surface: up-band y < 16 + 24 = 40; the text area
|
||||
// ends at 574 (status band, Q#S3), so the down-band is
|
||||
// y > 574 - 24 = 550.
|
||||
assert_eq!(edge_scroll_direction(10.0, 600), Some(-1));
|
||||
assert_eq!(edge_scroll_direction(39.9, 600), Some(-1));
|
||||
assert_eq!(edge_scroll_direction(40.0, 600), None, "interior");
|
||||
assert_eq!(edge_scroll_direction(300.0, 600), None);
|
||||
assert_eq!(
|
||||
edge_scroll_direction(576.0, 600),
|
||||
edge_scroll_direction(550.0, 600),
|
||||
None,
|
||||
"band edge exclusive"
|
||||
);
|
||||
assert_eq!(edge_scroll_direction(577.0, 600), Some(1));
|
||||
assert_eq!(edge_scroll_direction(551.0, 600), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_indicator_matches_tui_formula() {
|
||||
// Verbatim port of the TUI's format (Q#S1) — both frontends
|
||||
// must read the same.
|
||||
assert_eq!(format_scroll_indicator(0, 10, 1, 0), "All");
|
||||
assert_eq!(format_scroll_indicator(0, 50, 30, 10), "All");
|
||||
assert_eq!(format_scroll_indicator(0, 10, 100, 5), "Top");
|
||||
assert_eq!(format_scroll_indicator(90, 10, 100, 95), "Bot");
|
||||
assert_eq!(format_scroll_indicator(40, 10, 100, 49), "50%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -724,6 +724,26 @@ pub enum InstanceMessage {
|
|||
/// from line 0. Empty when the buffer is empty.
|
||||
lines: Vec<crate::cell::Style>,
|
||||
},
|
||||
/// Q#S1 (status band, protocol v8) — instance-authoritative
|
||||
/// status facts a semantic frontend cannot derive locally:
|
||||
/// buffer name, modified flag, whole-file diagnostic counts.
|
||||
/// Cursor position and scroll stay frontend-derived (the
|
||||
/// optimistic caret must not lag a round trip). Emitted by the
|
||||
/// semantic producer when any fact changes; kept off wires
|
||||
/// negotiated `< 8` (additive variant — an older peer would
|
||||
/// hard-error decoding it).
|
||||
StatusFacts {
|
||||
/// Buffer these facts describe.
|
||||
buffer_id: crate::BufferId,
|
||||
/// Buffer display name.
|
||||
name: String,
|
||||
/// Unsaved-changes flag.
|
||||
modified: bool,
|
||||
/// Whole-file `Error`-severity diagnostic count.
|
||||
diag_errors: u32,
|
||||
/// Whole-file `Warning`-severity diagnostic count.
|
||||
diag_warnings: u32,
|
||||
},
|
||||
/// T M11.1 — diff zones, folded-region placeholders, anything
|
||||
/// occupying its own vertical band. Anchored to the offset of the
|
||||
/// line it precedes or replaces; the frontend allocates the
|
||||
|
|
@ -1044,7 +1064,13 @@ pub enum ResourceBody {
|
|||
/// (sent only when the instance's `Hello.protocol_version >= 7`),
|
||||
/// so the compat ladder restarts on the v6 encoding floor —
|
||||
/// `SUPPORTED_PROTOCOL_VERSIONS` grows to `[6, 7]`.
|
||||
pub const PROTOCOL_VERSION: u32 = 7;
|
||||
///
|
||||
/// Q#S1 (status band): bumped from 7 to 8 for
|
||||
/// [`InstanceMessage::StatusFacts`]. Additive again, gated in the
|
||||
/// *daemon* this time (the variant travels instance→frontend): the
|
||||
/// per-session filter keeps it off wires negotiated `< 8`, the same
|
||||
/// shape as the `DispatchIdle` (v4) gate.
|
||||
pub const PROTOCOL_VERSION: u32 = 8;
|
||||
|
||||
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
|
||||
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
|
||||
|
|
@ -1081,7 +1107,10 @@ pub const PROTOCOL_VERSION: u32 = 7;
|
|||
/// and frontend-gated (like `Pointer` itself at v5), so the ladder
|
||||
/// resumes: v6 and v7 binaries interoperate, with the new variant
|
||||
/// kept off wires whose instance negotiated `< 7`.
|
||||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7];
|
||||
///
|
||||
/// Q#S1: extended to `[6, 7, 8]`. `InstanceMessage::StatusFacts` is
|
||||
/// additive and daemon-gated per session.
|
||||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8];
|
||||
|
||||
/// T M10.5: predicate for the handshake check. Returns `true` if
|
||||
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].
|
||||
|
|
|
|||
|
|
@ -1015,7 +1015,18 @@ fn dispatcher_loop(
|
|||
if let Some(stream) = streams.get_mut(fid)
|
||||
&& !write_failed
|
||||
{
|
||||
// Q#S1 — `StatusFacts` is a v8 variant; an older peer
|
||||
// would hard-error decoding it. Same per-session gate
|
||||
// shape as `DispatchIdle` (v4).
|
||||
let peer_knows_status_facts = session_registry
|
||||
.session_state(*fid)
|
||||
.is_some_and(|s| s.negotiated_protocol_version >= 8);
|
||||
for msg in &messages {
|
||||
if !peer_knows_status_facts
|
||||
&& matches!(msg, InstanceMessage::StatusFacts { .. })
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// T M10.10 Day 4 / M10.11 F2 — the criterion-1
|
||||
// jitter site: render-write latency.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -383,6 +383,7 @@ impl Frontend {
|
|||
| InstanceMessage::BlockAdornments { .. }
|
||||
| InstanceMessage::FoldState { .. }
|
||||
| InstanceMessage::FileStyleSummary { .. }
|
||||
| InstanceMessage::StatusFacts { .. }
|
||||
| InstanceMessage::ResourceOffer { .. }
|
||||
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
|
||||
// optimistic-apply gate; if any reaches this render path
|
||||
|
|
|
|||
|
|
@ -1683,7 +1683,7 @@ mod tests {
|
|||
// --- M5.5a handshake & postcard round-trips ---
|
||||
|
||||
#[test]
|
||||
fn protocol_version_is_seven_for_triple_click() {
|
||||
fn protocol_version_is_eight_for_status_facts() {
|
||||
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
|
||||
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
|
||||
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
|
||||
|
|
@ -1693,7 +1693,9 @@ mod tests {
|
|||
// bump that changed an existing struct's postcard encoding,
|
||||
// making v6 the ladder's encoding floor. Q#M4 bumped 6→7
|
||||
// (`PointerKind::TripleDown`, additive + frontend-gated).
|
||||
assert_eq!(PROTOCOL_VERSION, 7);
|
||||
// Q#S1 bumped 7→8 (`InstanceMessage::StatusFacts`, additive
|
||||
// + daemon-gated per session).
|
||||
assert_eq!(PROTOCOL_VERSION, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1702,14 +1704,16 @@ mod tests {
|
|||
// every cell-carrying message, ending the v1–v5 ladder —
|
||||
// pre-v6 peers are refused at the handshake (a clean
|
||||
// VersionMismatch) rather than garbling postcard mid-session.
|
||||
// Q#M4: the ladder resumes above that floor — v7 is additive
|
||||
// (`TripleDown`, frontend-gated), so v6 and v7 interoperate.
|
||||
// Q#M4 / Q#S1: the ladder resumes above that floor — v7
|
||||
// (`TripleDown`, frontend-gated) and v8 (`StatusFacts`,
|
||||
// daemon-gated) are additive, so v6 through v8 interoperate.
|
||||
assert!(is_supported_protocol_version(6));
|
||||
assert!(is_supported_protocol_version(7));
|
||||
for rejected in [0, 1, 2, 3, 4, 5, 8, u32::MAX] {
|
||||
assert!(is_supported_protocol_version(8));
|
||||
for rejected in [0, 1, 2, 3, 4, 5, 9, u32::MAX] {
|
||||
assert!(
|
||||
!is_supported_protocol_version(rejected),
|
||||
"v{rejected} must be rejected by a v7 binary"
|
||||
"v{rejected} must be rejected by a v8 binary"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,6 +118,9 @@ pub struct SemanticRenderState {
|
|||
/// bump, so the epoch half catches republishes (minimap marks,
|
||||
/// T M4.6 GPU parity).
|
||||
last_summary: HashMap<BufferId, (u64, u64)>,
|
||||
/// `(name, modified, diag_errors, diag_warnings)` last emitted as
|
||||
/// `StatusFacts` (Q#S1) — cached-compare suppression.
|
||||
last_status: HashMap<BufferId, (String, bool, u32, u32)>,
|
||||
/// `StyleSpans` recompute gate (perf). `scoped_style_spans` runs
|
||||
/// the tree-sitter highlights query over the *whole declared
|
||||
/// viewport* (which the GPU frontend sets to the entire buffer)
|
||||
|
|
@ -187,6 +190,7 @@ impl SemanticRenderState {
|
|||
last_decorations: HashMap::new(),
|
||||
last_adornments: HashMap::new(),
|
||||
last_summary: HashMap::new(),
|
||||
last_status: HashMap::new(),
|
||||
last_style_gate: HashMap::new(),
|
||||
diag_line_cache: HashMap::new(),
|
||||
}
|
||||
|
|
@ -383,9 +387,70 @@ impl SemanticRenderState {
|
|||
out.extend(self.inline_adornments_msg(state, &vp));
|
||||
// --- FileStyleSummary (minimap producer; Open Q#2) ---
|
||||
out.extend(self.file_style_summary_msg(state, vp.buffer_id, generation));
|
||||
// --- StatusFacts (status band; Q#S1, protocol v8) ---
|
||||
out.extend(self.status_facts_msg(state, vp.buffer_id));
|
||||
out
|
||||
}
|
||||
|
||||
/// The `StatusFacts` message for this frame, or `None` when
|
||||
/// nothing changed. Carries the facts a semantic frontend cannot
|
||||
/// derive locally: buffer name, modified flag, whole-file
|
||||
/// diagnostic counts (errors / warnings). Counts freeze at their
|
||||
/// last value while the diag store is stale — mid-edit positions
|
||||
/// are wrong but *counts* merely lag, and flickering to zero on
|
||||
/// every keystroke would be worse. The daemon's write loop keeps
|
||||
/// the variant off wires negotiated `< 8`.
|
||||
fn status_facts_msg(
|
||||
&mut self,
|
||||
state: &EditorState,
|
||||
buffer_id: BufferId,
|
||||
) -> Option<InstanceMessage> {
|
||||
let (name, modified) = {
|
||||
let core = state.core.borrow();
|
||||
let registry = core.registry.clone();
|
||||
let reg = registry.borrow();
|
||||
let buf = reg.get(buffer_id).ok()?;
|
||||
(buf.name().to_owned(), buf.is_modified())
|
||||
};
|
||||
let counts = {
|
||||
let core = state.core.borrow();
|
||||
buffer_file_uri(&core, buffer_id).and_then(|uri| {
|
||||
let store = state.lsp_manager.borrow().diag_store();
|
||||
let guard = store.lock().expect("diag store mutex poisoned");
|
||||
if guard.is_stale(&uri) {
|
||||
None // keep the cached counts
|
||||
} else {
|
||||
let mut errors = 0u32;
|
||||
let mut warnings = 0u32;
|
||||
for d in guard.for_uri(&uri) {
|
||||
match d.severity {
|
||||
crate::diag::DiagnosticSeverity::Error => errors += 1,
|
||||
crate::diag::DiagnosticSeverity::Warning => warnings += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some((errors, warnings))
|
||||
}
|
||||
})
|
||||
};
|
||||
let cached = self.last_status.get(&buffer_id);
|
||||
let (diag_errors, diag_warnings) =
|
||||
counts.unwrap_or_else(|| cached.map_or((0, 0), |c| (c.2, c.3)));
|
||||
let facts = (name, modified, diag_errors, diag_warnings);
|
||||
if cached == Some(&facts) {
|
||||
return None;
|
||||
}
|
||||
let msg = InstanceMessage::StatusFacts {
|
||||
buffer_id,
|
||||
name: facts.0.clone(),
|
||||
modified: facts.1,
|
||||
diag_errors,
|
||||
diag_warnings,
|
||||
};
|
||||
self.last_status.insert(buffer_id, facts);
|
||||
Some(msg)
|
||||
}
|
||||
|
||||
/// The `InlineAdornments` message for this frame, or `None` when
|
||||
/// nothing should be sent. The wire variant has no
|
||||
/// `generation`/`full`/`segments`, so this is M11.2-level
|
||||
|
|
@ -1368,9 +1433,10 @@ mod tests {
|
|||
}
|
||||
|
||||
/// All `InstanceMessage` variants the semantic projection may
|
||||
/// emit are `StyleSpans`, `Decorations`, `InlineAdornments`, or
|
||||
/// `FileStyleSummary` — never `CellDelta`, grid `Cursor`, or the
|
||||
/// still-unwired `BlockAdornments` / `FoldState` families.
|
||||
/// emit are `StyleSpans`, `Decorations`, `InlineAdornments`,
|
||||
/// `FileStyleSummary`, or `StatusFacts` (Q#S1) — never
|
||||
/// `CellDelta`, grid `Cursor`, or the still-unwired
|
||||
/// `BlockAdornments` / `FoldState` families.
|
||||
fn assert_semantic_only(msgs: &[InstanceMessage]) {
|
||||
for m in msgs {
|
||||
assert!(
|
||||
|
|
@ -1380,6 +1446,7 @@ mod tests {
|
|||
| InstanceMessage::Decorations { .. }
|
||||
| InstanceMessage::InlineAdornments { .. }
|
||||
| InstanceMessage::FileStyleSummary { .. }
|
||||
| InstanceMessage::StatusFacts { .. }
|
||||
),
|
||||
"semantic projection emitted an unexpected variant: {m:?}"
|
||||
);
|
||||
|
|
@ -1478,12 +1545,13 @@ mod tests {
|
|||
// the first frame is a `full` resync for both diffable families
|
||||
// (the frontend clears its viewport), carrying empty segments.
|
||||
// FileStyleSummary also emits on the first frame for this buffer
|
||||
// (post-M11 minimap producer, generation-keyed).
|
||||
// (post-M11 minimap producer, generation-keyed), as does
|
||||
// StatusFacts (Q#S1, cached-compare).
|
||||
let first = s.render_frame(&state);
|
||||
assert_eq!(
|
||||
first.len(),
|
||||
3,
|
||||
"first frame ships StyleSpans + Decorations + FileStyleSummary"
|
||||
4,
|
||||
"first frame ships StyleSpans + Decorations + FileStyleSummary + StatusFacts"
|
||||
);
|
||||
assert_semantic_only(&first);
|
||||
let (style_full, _) = style_segments(&first).expect("StyleSpans present");
|
||||
|
|
@ -2806,6 +2874,63 @@ mod tests {
|
|||
assert_eq!(lines[3], Style::default(), "trailing empty line → default");
|
||||
}
|
||||
|
||||
fn facts_of(msgs: &[InstanceMessage]) -> Option<(String, bool, u32, u32)> {
|
||||
msgs.iter().find_map(|m| match m {
|
||||
InstanceMessage::StatusFacts {
|
||||
name,
|
||||
modified,
|
||||
diag_errors,
|
||||
diag_warnings,
|
||||
..
|
||||
} => Some((name.clone(), *modified, *diag_errors, *diag_warnings)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_facts_emit_on_change_and_freeze_counts_while_stale() {
|
||||
let state = empty_state();
|
||||
let mut s = local();
|
||||
let bid = active_buffer(&state);
|
||||
// "abc\nde" + a Warning diagnostic + file path; the seeding
|
||||
// edit flips `modified`.
|
||||
seed_diagnostic(&state, bid);
|
||||
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
|
||||
|
||||
let first = s.render_frame(&state);
|
||||
let (_, modified, errors, warnings) = facts_of(&first).expect("first frame ships facts");
|
||||
assert!(modified, "the seeding edit dirtied the buffer");
|
||||
assert_eq!((errors, warnings), (0, 1));
|
||||
|
||||
// Nothing changed → suppressed.
|
||||
assert!(facts_of(&s.render_frame(&state)).is_none());
|
||||
|
||||
// Republish as an Error → re-emit with new counts.
|
||||
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/m114.rs"));
|
||||
let store = state.lsp_manager.borrow().diag_store();
|
||||
store.lock().expect("diag store").set(
|
||||
&uri,
|
||||
vec![crate::diag::Diagnostic {
|
||||
start_line: 0,
|
||||
start_col: 0,
|
||||
end_line: 0,
|
||||
end_col: 3,
|
||||
severity: crate::diag::DiagnosticSeverity::Error,
|
||||
message: "boom".into(),
|
||||
source: None,
|
||||
code: None,
|
||||
}],
|
||||
);
|
||||
let (_, _, errors, warnings) =
|
||||
facts_of(&s.render_frame(&state)).expect("republish re-emits");
|
||||
assert_eq!((errors, warnings), (1, 0));
|
||||
|
||||
// Stale store: counts freeze at the cached value instead of
|
||||
// flickering to zero, so no re-emission either.
|
||||
store.lock().expect("diag store").mark_stale(&uri);
|
||||
assert!(facts_of(&s.render_frame(&state)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_width_diagnostics_widen_to_a_visible_byte() {
|
||||
// "abc\nde" — line starts [0, 4], source_len 6; line 0
|
||||
|
|
|
|||
Loading…
Reference in New Issue