Merge pull request #93 from levineuwirth/session-completion-popup-p2
feat(completion): GPU dropdown over protocol v15 — Arc 1a phase 2
This commit is contained in:
commit
bb82db32b8
|
|
@ -313,6 +313,47 @@ Also caught by the new acceptance suite pre-validation:
|
|||
`install_completion` rebuilt `pmacs.completion` and clobbered the
|
||||
popup bindings — all `pmacs.completion` installers now merge.
|
||||
|
||||
## As-built notes — phase 2 (PR #93)
|
||||
|
||||
Protocol v15 + the GPU dropdown, close to the framing with four
|
||||
divergences worth recording:
|
||||
|
||||
1. **Q#C6 landed much narrower than framed.** The full
|
||||
`is_completion_control_key` predicate proved unnecessary:
|
||||
C-n/C-p/C-g already round-trip as command chords and Up/Down as
|
||||
forwarded motion keys, so the daemon's completion shadow handles
|
||||
them with zero GPU changes. Only two GPU defaults are wrong under
|
||||
a popup and got gated on `completion_open`: **Esc** (round-trips
|
||||
to dismiss instead of the local quit) and **RET/TAB** (skip the
|
||||
optimistic insert so they accept instead of typing `\n`/`\t`).
|
||||
Bet #1 (the family generalizes) and this simplification both held;
|
||||
bet #3's ESC prediction was exactly right.
|
||||
2. **The producer needed a multi-frontend rule the family didn't
|
||||
have**: the session is window-stamped and `SemanticRenderState` is
|
||||
per-frontend, so `completion_popup_msg` emits the popup only to
|
||||
the frontend whose own window owns the session — TUI typing never
|
||||
raises the GPU dropdown, and vice versa.
|
||||
3. **v15 also carries `StatusFacts.message`** (validation finding):
|
||||
`pmacs.editor.set_status` output — "12 references", LSP errors —
|
||||
was TUI-only (the grid ships the bottom row; the wire never
|
||||
carried the message). The band shows it echo-area style; its gate
|
||||
moved 8 → 15 (encoding change). Corollary fix: the optimistic
|
||||
CrdtOp path (`handle_remote_crdt_op`) now clears `core.status`
|
||||
like `dispatch_key`'s entry clear, else a message wedges through
|
||||
ordinary GPU typing.
|
||||
4. **Buffer switches need a local clear, not just wire closes**
|
||||
(validation finding): the producer's first-sight-closed silence
|
||||
means no close ever ships for a viewport that no longer exists, so
|
||||
the `BufferSnapshot` arm clears the popup mirror, and
|
||||
`CompletionLocal` carries its `buffer_id` with a shared
|
||||
`completion_open_for_current_buffer()` predicate gating keys and
|
||||
painting — a stale mirror can neither act nor draw against a
|
||||
foreign buffer.
|
||||
|
||||
Phase-3 items absorbed along the way: `isIncomplete` re-query and
|
||||
per-server trigger chars shipped with the phase-1 findings round;
|
||||
these as-built notes close the docs item.
|
||||
|
||||
## Deferred (named, not silently dropped)
|
||||
|
||||
- Snippet tabstops/placeholders (v1 inserts bodies literally).
|
||||
|
|
|
|||
|
|
@ -34,10 +34,10 @@ use glyphon::{
|
|||
};
|
||||
use loro::{ContainerTrait, ExportMode};
|
||||
use pmacs_protocol::{
|
||||
AdornmentContent, AdornmentPlacement, BufferId, ByteRange, CrdtOp, Decoration, DecorationKind,
|
||||
DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, InstanceSignal,
|
||||
Key as ProtocolKey, LineNumberMode, MenuPromptRow, Modifiers, PointerKind, SelectionSnapshot,
|
||||
StyleSegment, StyleSpan,
|
||||
AdornmentContent, AdornmentPlacement, BufferId, ByteRange, CompletionPopupRow, CrdtOp,
|
||||
Decoration, DecorationKind, DecorationSegment, FrontendId, InlineAdornment, InstanceMessage,
|
||||
InstanceSignal, Key as ProtocolKey, LineNumberMode, MenuPromptRow, Modifiers, PointerKind,
|
||||
SelectionSnapshot, StyleSegment, StyleSpan,
|
||||
cell::{Color as CellColor, Style as CellStyle},
|
||||
};
|
||||
use wgpu::MultisampleState;
|
||||
|
|
@ -652,6 +652,17 @@ struct State {
|
|||
mb_text_renderer: TextRenderer,
|
||||
/// Minibuffer dropdown background + selection quads (Q#MB1).
|
||||
mb_bg_vertex_buffer: ReusableVertexBuffer,
|
||||
/// Arc 1a Q#C5 — the live in-buffer completion popup (protocol
|
||||
/// v15), or `None` when closed.
|
||||
completion: Option<CompletionLocal>,
|
||||
/// Shaped row text for the completion dropdown, one line per
|
||||
/// candidate ("glyph label detail").
|
||||
completion_buffer: Buffer,
|
||||
/// Dedicated text renderer for the completion dropdown (its own
|
||||
/// layer over the buffer, like the menu's / minibuffer's).
|
||||
completion_text_renderer: TextRenderer,
|
||||
/// Completion dropdown background + selection quads.
|
||||
completion_bg_vertex_buffer: ReusableVertexBuffer,
|
||||
/// Minimap vertex bytes cached by [`MinimapCacheKey`] —
|
||||
/// rebuilding rescanned every line shape per frame.
|
||||
minimap_cache: Option<(MinimapCacheKey, Vec<u8>)>,
|
||||
|
|
@ -668,8 +679,31 @@ struct State {
|
|||
gutter_text_renderer: TextRenderer,
|
||||
}
|
||||
|
||||
/// The wire-authoritative status facts (Q#S1, protocol v8),
|
||||
/// mirrored from `InstanceMessage::StatusFacts`.
|
||||
/// Kind-glyph column for a completion row: the LSP
|
||||
/// `CompletionItemKind` numeric code → the single-char glyph the TUI
|
||||
/// popup uses (`crate::completion::CompletionItemKind::glyph`'s
|
||||
/// mapping, replicated — the GPU crate doesn't depend on `pmacs`).
|
||||
/// Unknown codes fall back to the plain-text dot, per the LSP
|
||||
/// "accept extended kinds gracefully" contract.
|
||||
fn completion_kind_glyph(kind: u8) -> char {
|
||||
match kind {
|
||||
2..=4 => 'f', // method / function / constructor
|
||||
5 | 10 => 'p', // field / property
|
||||
6 | 21 => 'v', // variable / constant
|
||||
7 | 22 => 'C', // class / struct
|
||||
8 => 'I', // interface
|
||||
9 => 'M', // module
|
||||
13 | 20 => 'E', // enum / enum member
|
||||
14 => 'k', // keyword
|
||||
15 => 's', // snippet
|
||||
25 => 't', // type parameter
|
||||
17 | 19 => '/', // file / folder
|
||||
_ => '.',
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire-authoritative status facts (Q#S1, protocol v8; `message`
|
||||
/// since v15), mirrored from `InstanceMessage::StatusFacts`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct StatusFactsLocal {
|
||||
buffer_id: BufferId,
|
||||
|
|
@ -677,6 +711,9 @@ struct StatusFactsLocal {
|
|||
modified: bool,
|
||||
diag_errors: u32,
|
||||
diag_warnings: u32,
|
||||
/// The core's transient status message (`pmacs.editor.set_status`
|
||||
/// — "12 references", LSP errors, ...), or `None` when clear.
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
/// The live incremental-search prompt (Q#SR5/Q#RX6, protocol v10),
|
||||
|
|
@ -705,6 +742,40 @@ struct MinibufferLocal {
|
|||
total: u32,
|
||||
}
|
||||
|
||||
/// The live in-buffer completion popup (Arc 1a Q#C5, protocol v15),
|
||||
/// mirrored from a `CompletionPopup` whose `anchor` was `Some`. The
|
||||
/// dropdown anchors at the glyph rect of `anchor` (a byte offset —
|
||||
/// the caret mapping reused), one row per candidate; navigation and
|
||||
/// accept round-trip into the daemon's completion shadow.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct CompletionLocal {
|
||||
/// Buffer the popup targets. Rendering and the key gates check
|
||||
/// this against `current_buffer_id` so a popup can never act
|
||||
/// against a buffer it wasn't opened in (buffer switches also
|
||||
/// clear the whole mirror at the `BufferSnapshot` arm; this is
|
||||
/// the belt to that suspender).
|
||||
buffer_id: BufferId,
|
||||
/// Byte offset of the prefix start.
|
||||
anchor: u64,
|
||||
/// Bytes of typed prefix at `anchor` (reserved for a bolded-
|
||||
/// prefix refinement; unused by the first render).
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "shipped on the wire for the bolded-prefix refinement"
|
||||
)]
|
||||
prefix_len: u32,
|
||||
/// Windowed candidate rows (label / kind / detail), best-first.
|
||||
rows: Vec<CompletionPopupRow>,
|
||||
/// Highlighted row within `rows`.
|
||||
selected: Option<u32>,
|
||||
/// Total candidate count (reserved for an "i/total" hint).
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "shipped on the wire for the i/total hint refinement"
|
||||
)]
|
||||
total: u32,
|
||||
}
|
||||
|
||||
/// The live context menu (Q#CM1, protocol v11), mirrored from a
|
||||
/// `MenuPrompt` with non-empty rows. The popup draws at `anchor_px`
|
||||
/// (the right-click pixel, remembered locally — the daemon never sees
|
||||
|
|
@ -849,10 +920,26 @@ impl ApplicationHandler<AppEvent> for App {
|
|||
.as_ref()
|
||||
.is_some_and(State::daemon_intercepts_keys);
|
||||
|
||||
// Arc 1a Q#C6 — the completion popup is NON-modal, so it
|
||||
// never flips the intercept gate (typing stays
|
||||
// optimistic; the daemon's after-edit refresh re-ships
|
||||
// the popup). Only the keys whose *default GPU handling
|
||||
// is wrong under a popup* need this flag: Esc (below,
|
||||
// else it's the local quit) and RET/TAB (the optimistic
|
||||
// gate further down, else they'd insert instead of
|
||||
// accept). C-n/C-p/C-g already round-trip as command
|
||||
// chords, Up/Down as forwarded motion keys — the daemon's
|
||||
// completion shadow handles all of them.
|
||||
let completion_open = self
|
||||
.state
|
||||
.as_ref()
|
||||
.is_some_and(State::completion_open_for_current_buffer);
|
||||
|
||||
// Escape cancels an active intercept (e.g. a running
|
||||
// search); otherwise it stays the local quit.
|
||||
// search) or dismisses the completion popup; otherwise it
|
||||
// stays the local quit.
|
||||
if matches!(key.logical_key, Key::Named(NamedKey::Escape)) {
|
||||
if intercept {
|
||||
if intercept || completion_open {
|
||||
if let Some(client) = self.attach_client.as_ref()
|
||||
&& let Err(e) = client.send_key(ProtocolKey::Escape, Modifiers::NONE)
|
||||
{
|
||||
|
|
@ -956,11 +1043,21 @@ impl ApplicationHandler<AppEvent> for App {
|
|||
return;
|
||||
}
|
||||
|
||||
if let Some(op) = self.state.as_mut().and_then(|state| {
|
||||
state
|
||||
.optimistic_crdt_insert(pkey, pmods)
|
||||
.or_else(|| state.optimistic_crdt_delete(pkey, pmods))
|
||||
}) {
|
||||
// Arc 1a Q#C6 — with the popup open, RET and TAB mean
|
||||
// "accept", not "insert \n / \t": skip the optimistic
|
||||
// path so they round-trip into the daemon's
|
||||
// dispatch_completion_key. Everything else stays
|
||||
// optimistic.
|
||||
let completion_takes_key =
|
||||
completion_open && matches!(pkey, ProtocolKey::Enter | ProtocolKey::Tab);
|
||||
|
||||
if !completion_takes_key
|
||||
&& let Some(op) = self.state.as_mut().and_then(|state| {
|
||||
state
|
||||
.optimistic_crdt_insert(pkey, pmods)
|
||||
.or_else(|| state.optimistic_crdt_delete(pkey, pmods))
|
||||
})
|
||||
{
|
||||
if debug_input() {
|
||||
eprintln!(
|
||||
"pmacs-gpu send_crdt: key={pkey:?} buf={:?} bytes={}B",
|
||||
|
|
@ -1744,6 +1841,9 @@ impl State {
|
|||
// Q#MB1 — a third renderer for the minibuffer dropdown layer.
|
||||
let mb_text_renderer =
|
||||
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
|
||||
// Arc 1a Q#C5 — a renderer for the completion dropdown layer.
|
||||
let completion_text_renderer =
|
||||
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
|
||||
// UX gutter — a renderer for the line-number layer.
|
||||
let gutter_text_renderer =
|
||||
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
|
||||
|
|
@ -1796,6 +1896,17 @@ impl State {
|
|||
Some(MB_DROP_MAX_WIDTH),
|
||||
Some(config.height as f32),
|
||||
);
|
||||
// Completion dropdown buffer (Arc 1a): the minibuffer
|
||||
// dropdown's metrics, its own layer.
|
||||
let mut completion_buffer = Buffer::new(
|
||||
&mut font_system,
|
||||
Metrics::new(MB_DROP_FONT_SIZE, MB_DROP_LINE_HEIGHT),
|
||||
);
|
||||
completion_buffer.set_size(
|
||||
&mut font_system,
|
||||
Some(MB_DROP_MAX_WIDTH),
|
||||
Some(config.height as f32),
|
||||
);
|
||||
// Line-number gutter buffer (UX gutter arc): same font size + line
|
||||
// height as the code buffer so its rows align one-for-one.
|
||||
let mut gutter_buffer = Buffer::new(
|
||||
|
|
@ -1888,6 +1999,10 @@ impl State {
|
|||
mb_buffer,
|
||||
mb_text_renderer,
|
||||
mb_bg_vertex_buffer: ReusableVertexBuffer::new(),
|
||||
completion: None,
|
||||
completion_buffer,
|
||||
completion_text_renderer,
|
||||
completion_bg_vertex_buffer: ReusableVertexBuffer::new(),
|
||||
minimap_cache: None,
|
||||
line_numbers: LineNumberMode::Off,
|
||||
gutter_buffer,
|
||||
|
|
@ -2319,6 +2434,14 @@ impl State {
|
|||
// next PresenceUpdate / CursorByte arrives.
|
||||
self.peer_presences.clear();
|
||||
self.own_cursor = None;
|
||||
// The completion popup too (Arc 1a): its anchor is a
|
||||
// byte in the prior buffer, and the producer never
|
||||
// ships a close for a viewport that no longer exists
|
||||
// (first-sight of the new buffer stays silent) — a
|
||||
// retained popup would render against the new rope AND
|
||||
// keep hijacking Esc/RET/TAB. The daemon-side session
|
||||
// was already invalidated by the switch.
|
||||
self.completion = None;
|
||||
self.cursor_fresh = false;
|
||||
self.optimistic_cursor_floor = None;
|
||||
self.optimistic_floor_set_at = None;
|
||||
|
|
@ -2520,14 +2643,17 @@ 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.
|
||||
// Q#S1 (protocol v8; `message` since v15) — the
|
||||
// wire-authoritative half of the status band: name,
|
||||
// modified, whole-file diag counts, and the transient
|
||||
// status message (LSP command summaries).
|
||||
InstanceMessage::StatusFacts {
|
||||
buffer_id,
|
||||
name,
|
||||
modified,
|
||||
diag_errors,
|
||||
diag_warnings,
|
||||
message,
|
||||
} => {
|
||||
self.status_facts = Some(StatusFactsLocal {
|
||||
buffer_id,
|
||||
|
|
@ -2535,6 +2661,7 @@ impl State {
|
|||
modified,
|
||||
diag_errors,
|
||||
diag_warnings,
|
||||
message,
|
||||
});
|
||||
self.request_redraw();
|
||||
None
|
||||
|
|
@ -2728,10 +2855,53 @@ impl State {
|
|||
self.request_redraw();
|
||||
None
|
||||
}
|
||||
// Arc 1a Q#C5/Q#C6 — the in-buffer completion dropdown.
|
||||
// A close (`anchor: None`) always applies — the daemon may
|
||||
// ship it carrying a buffer this window just switched away
|
||||
// from, and dropping it would wedge a stale popup. An OPEN
|
||||
// for a buffer this window isn't showing is dropped (the
|
||||
// CrdtOp rule).
|
||||
InstanceMessage::CompletionPopup {
|
||||
buffer_id,
|
||||
anchor,
|
||||
prefix_len,
|
||||
rows,
|
||||
selected,
|
||||
total,
|
||||
} => {
|
||||
let Some(anchor) = anchor else {
|
||||
self.completion = None;
|
||||
self.request_redraw();
|
||||
return None;
|
||||
};
|
||||
if self.current_buffer_id != Some(buffer_id) {
|
||||
return None;
|
||||
}
|
||||
self.completion = Some(CompletionLocal {
|
||||
buffer_id,
|
||||
anchor,
|
||||
prefix_len,
|
||||
rows,
|
||||
selected,
|
||||
total,
|
||||
});
|
||||
self.request_redraw();
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True while the completion popup is open **for the buffer this
|
||||
/// window currently shows** — the predicate the key gates (Esc,
|
||||
/// RET/TAB) and the render path share, so a stale mirror can
|
||||
/// never act against a foreign buffer.
|
||||
fn completion_open_for_current_buffer(&self) -> bool {
|
||||
self.completion
|
||||
.as_ref()
|
||||
.is_some_and(|c| Some(c.buffer_id) == self.current_buffer_id)
|
||||
}
|
||||
|
||||
/// A `ViewportSend` for the current `view_range` if it differs from
|
||||
/// the last one declared, else `None` (Q#S5 coalescing). `generation`
|
||||
/// is 0 — the producer's full-resync triggers on the visible-range
|
||||
|
|
@ -3288,6 +3458,19 @@ impl State {
|
|||
};
|
||||
return format!("{}{}{}", label, sp.query, count);
|
||||
}
|
||||
// A transient status message (v15 `StatusFacts.message` — LSP
|
||||
// command summaries like "12 references", error reports) takes
|
||||
// the band over echo-area style; the daemon clears it on the
|
||||
// next keypress, which ships a fresh `StatusFacts` and returns
|
||||
// the band to the buffer name.
|
||||
if let Some(msg) = self
|
||||
.status_facts
|
||||
.as_ref()
|
||||
.filter(|f| Some(f.buffer_id) == self.current_buffer_id)
|
||||
.and_then(|f| f.message.as_deref())
|
||||
{
|
||||
return msg.to_owned();
|
||||
}
|
||||
match self
|
||||
.status_facts
|
||||
.as_ref()
|
||||
|
|
@ -3510,6 +3693,170 @@ impl State {
|
|||
rects_to_vertex_bytes(&rects, self.config.width, self.config.height)
|
||||
}
|
||||
|
||||
/// Re-shape the completion dropdown rows (Arc 1a Q#C5), one line
|
||||
/// per candidate: kind glyph, label, then the dimmable detail.
|
||||
/// Empty when the popup is closed.
|
||||
fn refresh_completion_buffer(&mut self) {
|
||||
let text = self.completion.as_ref().map_or_else(String::new, |comp| {
|
||||
comp.rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let glyph = completion_kind_glyph(row.kind);
|
||||
match row.detail.as_deref() {
|
||||
Some(detail) => format!("{glyph} {} {detail}", row.label),
|
||||
None => format!("{glyph} {}", row.label),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
});
|
||||
self.completion_buffer.set_text(
|
||||
&mut self.font_system,
|
||||
&text,
|
||||
&Attrs::new().family(Family::Name("JetBrains Mono")),
|
||||
Shaping::Advanced,
|
||||
None,
|
||||
);
|
||||
self.completion_buffer
|
||||
.shape_until_scroll(&mut self.font_system, false);
|
||||
}
|
||||
|
||||
/// The pixel position of the completion popup's byte anchor:
|
||||
/// `(x, line_top_y, line_height)` of the glyph the anchor sits
|
||||
/// before — the caret mapping (`caret_rect`) reused for a second
|
||||
/// byte. `None` when the popup is closed or the anchor is
|
||||
/// scrolled out of the visible slice (the popup then simply
|
||||
/// doesn't draw this frame; scrolling back restores it).
|
||||
fn completion_anchor_px(&self) -> Option<(f32, f32, f32)> {
|
||||
if !self.completion_open_for_current_buffer() {
|
||||
return None; // never paint against a foreign buffer's rope
|
||||
}
|
||||
let comp = self.completion.as_ref()?;
|
||||
let (vstart, vend) = self.view_range;
|
||||
if vend <= vstart {
|
||||
return None;
|
||||
}
|
||||
let anchor = comp.anchor;
|
||||
if anchor < vstart || anchor > vend {
|
||||
return None;
|
||||
}
|
||||
let slice = &self.current_text[vstart as usize..vend as usize];
|
||||
let line_offsets = line_byte_offsets(slice);
|
||||
let slice_anchor = anchor - vstart;
|
||||
let (line_lo, _) = source_line_range(slice, slice_anchor);
|
||||
let text_left = self.text_left();
|
||||
for run in self.buffer.layout_runs() {
|
||||
if line_offsets.get(run.line_i).copied().unwrap_or(0) != line_lo {
|
||||
continue;
|
||||
}
|
||||
let mut x = text_left;
|
||||
for glyph in run.glyphs {
|
||||
if line_lo + glyph.start as u64 >= slice_anchor {
|
||||
x = text_left + glyph.x;
|
||||
break;
|
||||
}
|
||||
// Anchor is past this glyph; track its right edge so an
|
||||
// anchor at line end lands after the final glyph.
|
||||
x = text_left + glyph.x + glyph.w;
|
||||
}
|
||||
return Some((x, TEXT_TOP + run.line_top, run.line_height));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Layout of the completion dropdown: `(first_row, row_count,
|
||||
/// left_x, top_y)`. Anchored on the row *below* the anchor's line
|
||||
/// (growing downward toward the status band); flips above when
|
||||
/// nothing fits below — the TUI overlay's placement rule. The
|
||||
/// visible slice windows around the selection so it stays on
|
||||
/// screen when fewer rows fit than the wire shipped (the F-007
|
||||
/// discipline).
|
||||
fn completion_dropdown_layout(&self) -> Option<(usize, usize, f32, f32)> {
|
||||
let comp = self.completion.as_ref()?;
|
||||
let n = comp.rows.len();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let (ax, line_top, line_h) = self.completion_anchor_px()?;
|
||||
let band_top = text_area_bottom(self.config.height);
|
||||
let below_px = band_top - (line_top + line_h);
|
||||
let above_px = line_top - TEXT_TOP;
|
||||
let max_below = (below_px / MB_DROP_ROW_HEIGHT).floor() as usize;
|
||||
let max_above = (above_px / MB_DROP_ROW_HEIGHT).floor() as usize;
|
||||
let (avail, below) = if max_below >= 1 {
|
||||
(max_below, true)
|
||||
} else {
|
||||
(max_above, false)
|
||||
};
|
||||
if avail == 0 {
|
||||
return None;
|
||||
}
|
||||
let count = n.min(avail);
|
||||
let sel = comp.selected.map_or(0, |s| s as usize);
|
||||
let first = if n <= count {
|
||||
0
|
||||
} else {
|
||||
sel.saturating_sub(count / 2).min(n - count)
|
||||
};
|
||||
let top_y = if below {
|
||||
line_top + line_h
|
||||
} else {
|
||||
line_top - count as f32 * MB_DROP_ROW_HEIGHT
|
||||
};
|
||||
Some((first, count, ax, top_y))
|
||||
}
|
||||
|
||||
/// Dropdown geometry `(left, top_y, width)`: as wide as the widest
|
||||
/// row (clamped, the minibuffer bounds), left edge at the anchor
|
||||
/// column shifted back from the window's right margin.
|
||||
/// `refresh_completion_buffer` must have run so the width
|
||||
/// measurement is current.
|
||||
fn completion_dropdown_rect(&self) -> Option<(f32, f32, f32)> {
|
||||
let (_first, _count, ax, top_y) = self.completion_dropdown_layout()?;
|
||||
let widest = self
|
||||
.completion_buffer
|
||||
.layout_runs()
|
||||
.map(|r| r.line_w)
|
||||
.fold(0.0_f32, f32::max);
|
||||
let width = (widest + 2.0 * MB_DROP_PAD_X).clamp(MB_DROP_MIN_WIDTH, MB_DROP_MAX_WIDTH);
|
||||
let left = ax.min((self.config.width as f32 - width).max(0.0));
|
||||
Some((left, top_y, width))
|
||||
}
|
||||
|
||||
/// Completion dropdown background + selection-highlight quads.
|
||||
/// Empty when closed or the anchor is off-screen.
|
||||
fn completion_dropdown_vertex_bytes(&self) -> Vec<u8> {
|
||||
let Some(comp) = self.completion.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some((first, count, _ax, _ty)) = self.completion_dropdown_layout() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some((x, top_y, width)) = self.completion_dropdown_rect() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rects = vec![MinimapRect {
|
||||
x,
|
||||
y: top_y,
|
||||
w: width,
|
||||
h: count as f32 * MB_DROP_ROW_HEIGHT,
|
||||
color: MENU_BG,
|
||||
}];
|
||||
if let Some(sel) = comp.selected.map(|s| s as usize)
|
||||
&& sel >= first
|
||||
&& sel < first + count
|
||||
{
|
||||
rects.push(MinimapRect {
|
||||
x,
|
||||
y: top_y + (sel - first) as f32 * MB_DROP_ROW_HEIGHT,
|
||||
w: width,
|
||||
h: MB_DROP_ROW_HEIGHT,
|
||||
color: MENU_SELECTED_BG,
|
||||
});
|
||||
}
|
||||
rects_to_vertex_bytes(&rects, 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
|
||||
|
|
@ -3988,6 +4335,23 @@ impl State {
|
|||
&mb_vertices,
|
||||
)
|
||||
.cloned();
|
||||
// Arc 1a Q#C5 — the completion dropdown quads (bg + selection),
|
||||
// a layer over the code anchored at the popup's byte anchor.
|
||||
// `refresh_completion_buffer` first so the width measurement in
|
||||
// `completion_dropdown_vertex_bytes` is current.
|
||||
self.refresh_completion_buffer();
|
||||
let completion_vertices = self.completion_dropdown_vertex_bytes();
|
||||
let completion_vertex_count =
|
||||
(completion_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32;
|
||||
let completion_bg_buffer = self
|
||||
.completion_bg_vertex_buffer
|
||||
.upload(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
"pmacs-gpu completion dropdown",
|
||||
&completion_vertices,
|
||||
)
|
||||
.cloned();
|
||||
// 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();
|
||||
|
|
@ -4244,6 +4608,42 @@ impl State {
|
|||
)
|
||||
.expect("minibuffer text_renderer prepare");
|
||||
|
||||
// Arc 1a Q#C5 — prepare the completion dropdown glyphs in their
|
||||
// layer. The buffer is shaped with *all* wire rows; the layout
|
||||
// scrolls it up by `first` rows so row `first` lands at `top_y`,
|
||||
// and `bounds` clips the rows outside the visible window (the
|
||||
// minibuffer dropdown's F-007 shape).
|
||||
let completion_areas: Vec<TextArea> = self
|
||||
.completion_dropdown_layout()
|
||||
.zip(self.completion_dropdown_rect())
|
||||
.map(|((first, count, _ax, _ty), (x, top_y, width))| TextArea {
|
||||
buffer: &self.completion_buffer,
|
||||
left: x + MB_DROP_PAD_X,
|
||||
top: top_y - first as f32 * MB_DROP_ROW_HEIGHT,
|
||||
scale: 1.0,
|
||||
bounds: TextBounds {
|
||||
left: x as i32,
|
||||
top: top_y as i32,
|
||||
right: (x + width).round() as i32,
|
||||
bottom: (top_y + count as f32 * MB_DROP_ROW_HEIGHT).round() as i32,
|
||||
},
|
||||
default_color: Color::rgb(232, 232, 238),
|
||||
custom_glyphs: &[],
|
||||
})
|
||||
.into_iter()
|
||||
.collect();
|
||||
self.completion_text_renderer
|
||||
.prepare(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&mut self.font_system,
|
||||
&mut self.atlas,
|
||||
&self.viewport,
|
||||
completion_areas,
|
||||
&mut self.swash_cache,
|
||||
)
|
||||
.expect("completion text_renderer prepare");
|
||||
|
||||
let mut encoder = self
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
|
|
@ -4307,6 +4707,17 @@ impl State {
|
|||
self.mb_text_renderer
|
||||
.render(&self.atlas, &self.viewport, &mut pass)
|
||||
.expect("minibuffer text_renderer render");
|
||||
// Arc 1a Q#C5 — the completion dropdown floats over the
|
||||
// code at its byte anchor: bg + selection quads, then its
|
||||
// row glyphs on top (under the context menu, which stays
|
||||
// the topmost surface).
|
||||
if let Some(vertex_buffer) = completion_bg_buffer.as_ref() {
|
||||
self.quad_renderer
|
||||
.render(&mut pass, vertex_buffer, completion_vertex_count);
|
||||
}
|
||||
self.completion_text_renderer
|
||||
.render(&self.atlas, &self.viewport, &mut pass)
|
||||
.expect("completion text_renderer render");
|
||||
// Q#CM1 — the context menu draws last: its bg/highlight quads
|
||||
// occlude everything beneath, then its glyphs on top.
|
||||
if let Some(vertex_buffer) = menu_bg_buffer.as_ref() {
|
||||
|
|
@ -5201,6 +5612,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
|
|||
InstanceMessage::ResourceOffer { .. } => "ResourceOffer",
|
||||
InstanceMessage::DispatchIdle { .. } => "DispatchIdle",
|
||||
InstanceMessage::LineNumbers { .. } => "LineNumbers",
|
||||
InstanceMessage::CompletionPopup { .. } => "CompletionPopup",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -7438,6 +7850,50 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_popup_is_scoped_to_its_buffer() {
|
||||
// Buffer-switch regression (PR #93 validation finding 1): a
|
||||
// retained popup mirror must be inert — no key gating, no
|
||||
// anchor mapping — the moment `current_buffer_id` differs
|
||||
// from the popup's buffer.
|
||||
let Some(mut state) = headless_or_skip(320, 240, "hello_world he") else {
|
||||
return;
|
||||
};
|
||||
let own = BufferId::next();
|
||||
let other = BufferId::next();
|
||||
state.current_buffer_id = Some(own);
|
||||
// Headless states never declare a viewport; anchor mapping
|
||||
// reads `view_range`, so pin it to the whole text.
|
||||
state.view_range = (0, state.current_text.len() as u64);
|
||||
state.completion = Some(CompletionLocal {
|
||||
buffer_id: own,
|
||||
anchor: 12,
|
||||
prefix_len: 2,
|
||||
rows: vec![CompletionPopupRow {
|
||||
label: "hello_world".into(),
|
||||
kind: 3,
|
||||
detail: None,
|
||||
}],
|
||||
selected: Some(0),
|
||||
total: 1,
|
||||
});
|
||||
assert!(state.completion_open_for_current_buffer());
|
||||
assert!(
|
||||
state.completion_anchor_px().is_some(),
|
||||
"the popup anchors in its own buffer"
|
||||
);
|
||||
// The window switches buffers; the mirror is stale.
|
||||
state.current_buffer_id = Some(other);
|
||||
assert!(
|
||||
!state.completion_open_for_current_buffer(),
|
||||
"a foreign-buffer popup must not gate keys"
|
||||
);
|
||||
assert!(
|
||||
state.completion_anchor_px().is_none(),
|
||||
"a foreign-buffer popup must not paint"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_text_changes_the_rendered_frame() {
|
||||
let Some(mut empty) = headless_or_skip(320, 240, "") else {
|
||||
|
|
|
|||
|
|
@ -46,12 +46,12 @@ pub use cell::{
|
|||
pub use crdt::CrdtOp;
|
||||
pub use ids::{BufferId, ByteRange, FrontendId, Position};
|
||||
pub use message::{
|
||||
AdornmentContent, AdornmentPlacement, AttachRequest, BlockAdornment, CursorState, Decoration,
|
||||
DecorationKind, DecorationSegment, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello,
|
||||
InlineAdornment, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key,
|
||||
KeyEvent, LineNumberMode, MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind,
|
||||
NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody,
|
||||
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, StyleSpan,
|
||||
AdornmentContent, AdornmentPlacement, AttachRequest, BlockAdornment, CompletionPopupRow,
|
||||
CursorState, Decoration, DecorationKind, DecorationSegment, FrontendCapabilities,
|
||||
FrontendEvent, GoodbyeReason, Hello, InlineAdornment, InstanceCapabilities, InstanceIdentity,
|
||||
InstanceMessage, InstanceSignal, Key, KeyEvent, LineNumberMode, MenuPromptRow, Modifiers,
|
||||
MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind,
|
||||
ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, StyleSpan,
|
||||
is_supported_protocol_version, negotiate_capabilities,
|
||||
};
|
||||
pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message};
|
||||
|
|
|
|||
|
|
@ -764,6 +764,16 @@ pub enum InstanceMessage {
|
|||
diag_errors: u32,
|
||||
/// Whole-file `Warning`-severity diagnostic count.
|
||||
diag_warnings: u32,
|
||||
/// The core's transient status message (`pmacs.editor.
|
||||
/// set_status` — LSP command summaries like "12 references",
|
||||
/// error reports, ...), or `None` when clear. Added in v15:
|
||||
/// the attached TUI gets the message for free through the
|
||||
/// rendered cell grid's bottom row, but a semantic frontend
|
||||
/// only sees what's on this wire — without it every modeline
|
||||
/// summary was TUI-only. Encoding change to this variant; its
|
||||
/// daemon gate moved `>= 8` → `>= 15` (the v10 `SearchPrompt`
|
||||
/// / v14 `LineNumbers` precedent).
|
||||
message: Option<String>,
|
||||
},
|
||||
/// T M11.1 — diff zones, folded-region placeholders, anything
|
||||
/// occupying its own vertical band. Anchored to the offset of the
|
||||
|
|
@ -922,6 +932,32 @@ pub enum InstanceMessage {
|
|||
/// The line-number gutter mode for that window.
|
||||
mode: LineNumberMode,
|
||||
},
|
||||
/// In-buffer completion popup state for a semantic frontend
|
||||
/// (Arc 1a Q#C5, protocol v15). Unlike the band-anchored
|
||||
/// [`Self::MinibufferPrompt`], the popup is anchored *at a byte*
|
||||
/// (the typed prefix's start) — the frontend maps byte → glyph
|
||||
/// rect locally, exactly as it does for the caret, so the
|
||||
/// instance never learns a pixel. Rows are display-only: accept
|
||||
/// is a daemon round-trip (`dispatch_completion_key`), so insert
|
||||
/// text never ships. `anchor: None` clears the popup.
|
||||
/// Cached-compare suppressed like `SearchPrompt`; daemon-gated
|
||||
/// `>= 15`.
|
||||
CompletionPopup {
|
||||
/// Buffer the popup targets.
|
||||
buffer_id: crate::BufferId,
|
||||
/// Byte offset of the prefix start, or `None` when closed.
|
||||
anchor: Option<u64>,
|
||||
/// Bytes of typed prefix at `anchor` (a frontend may embolden
|
||||
/// the matched prefix within each label).
|
||||
prefix_len: u32,
|
||||
/// A windowed slice of the candidates (best-first, already
|
||||
/// scored/filtered by the core), `<= POPUP_VISIBLE`.
|
||||
rows: Vec<CompletionPopupRow>,
|
||||
/// Highlighted row *within* `rows`, or `None`.
|
||||
selected: Option<u32>,
|
||||
/// Total candidate count (the window is a slice of this).
|
||||
total: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// Line-number gutter mode for a window (UX gutter arc). Shared across the
|
||||
|
|
@ -976,6 +1012,21 @@ pub struct MenuPromptRow {
|
|||
pub separator: bool,
|
||||
}
|
||||
|
||||
/// One row of the in-buffer completion popup on the wire
|
||||
/// ([`InstanceMessage::CompletionPopup`]). Display fields only ---
|
||||
/// accept resolves daemon-side against the core session, so the
|
||||
/// insert text stays off the wire.
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CompletionPopupRow {
|
||||
/// Display label.
|
||||
pub label: String,
|
||||
/// LSP `CompletionItemKind` numeric code (1..=25; frontends map
|
||||
/// unknown codes to a plain-text glyph, per the LSP contract).
|
||||
pub kind: u8,
|
||||
/// Optional one-line detail rendered after the label.
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// Flat selection state for the wire.
|
||||
///
|
||||
/// Mirrors [`crate::window::Selection`] but as a self-contained pair
|
||||
|
|
@ -1252,7 +1303,18 @@ pub enum ResourceBody {
|
|||
/// message. Encoding change to that variant; daemon-gated `< 14` (a v13
|
||||
/// peer negotiates v13 and receives no `LineNumbers` rather than
|
||||
/// mis-decoding the wider shape), same shape as the v10 `SearchPrompt` bump.
|
||||
pub const PROTOCOL_VERSION: u32 = 14;
|
||||
///
|
||||
/// Completion popup (Arc 1a Q#C5): bumped 14 → 15 for
|
||||
/// [`InstanceMessage::CompletionPopup`] — a new additive variant
|
||||
/// carrying the byte-anchored in-buffer completion dropdown.
|
||||
/// Daemon-gated `< 15`; a v14 peer negotiates v14 and simply receives
|
||||
/// no `CompletionPopup` (completion still works via the daemon's TUI
|
||||
/// rendering and the key round-trip), like every prior additive bump.
|
||||
/// v15 also widened `StatusFacts` with the transient status `message`
|
||||
/// (encoding change to that variant; its gate moved `>= 8` → `>= 15`,
|
||||
/// so a v14 peer's status band goes dark rather than mis-decoding —
|
||||
/// the v10 `SearchPrompt` / v14 `LineNumbers` shape).
|
||||
pub const PROTOCOL_VERSION: u32 = 15;
|
||||
|
||||
/// 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
|
||||
|
|
@ -1309,7 +1371,10 @@ pub const PROTOCOL_VERSION: u32 = 14;
|
|||
/// Q#MB1: extended to `[6, 7, 8, 9, 10, 11, 12]`.
|
||||
/// `InstanceMessage::MinibufferPrompt` is additive and daemon-gated per
|
||||
/// session, so the ladder resumes again.
|
||||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14];
|
||||
///
|
||||
/// Q#C5: extended to `[6, ..., 15]`. `InstanceMessage::CompletionPopup`
|
||||
/// is additive and daemon-gated per session.
|
||||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
|
||||
|
||||
/// T M10.5: predicate for the handshake check. Returns `true` if
|
||||
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].
|
||||
|
|
|
|||
|
|
@ -582,8 +582,9 @@ const DEFAULT_POPUP_WIDTH: u32 = 40;
|
|||
|
||||
/// Rows the popup shows at once; when more candidates are live the
|
||||
/// visible slice windows around the selection (mirroring the
|
||||
/// minibuffer dropdown's `MB_VISIBLE` cap).
|
||||
const POPUP_MAX_ROWS: u32 = 10;
|
||||
/// minibuffer dropdown's `MB_VISIBLE` cap). Shared with the semantic
|
||||
/// producer so the wire window matches the TUI overlay's.
|
||||
pub(crate) const POPUP_MAX_ROWS: u32 = 10;
|
||||
|
||||
/// Minimum popup width in cells (glyph column + a readable label).
|
||||
const POPUP_MIN_WIDTH: u32 = 12;
|
||||
|
|
|
|||
|
|
@ -1034,9 +1034,14 @@ fn dispatcher_loop(
|
|||
// Q#S1 — `StatusFacts` is a v8 variant; an older peer
|
||||
// would hard-error decoding it. Same per-session gate
|
||||
// shape as `DispatchIdle` (v4).
|
||||
// `StatusFacts` gained the transient status `message`
|
||||
// in v15 (encoding change to the variant), so the gate
|
||||
// moved 8 → 15: an older peer's band goes dark rather
|
||||
// than mis-decoding the wider shape (the v10
|
||||
// SearchPrompt / v14 LineNumbers precedent).
|
||||
let peer_knows_status_facts = session_registry
|
||||
.session_state(*fid)
|
||||
.is_some_and(|s| s.negotiated_protocol_version >= 8);
|
||||
.is_some_and(|s| s.negotiated_protocol_version >= 15);
|
||||
// Q#SR5 / Q#RX6 — `SearchPrompt` gained regex/invalid
|
||||
// fields in v10 (encoding change); gate at >= 10 so a v9
|
||||
// peer is sent no SearchPrompt rather than the wider
|
||||
|
|
@ -1056,6 +1061,12 @@ fn dispatcher_loop(
|
|||
let peer_knows_line_numbers = session_registry
|
||||
.session_state(*fid)
|
||||
.is_some_and(|s| s.negotiated_protocol_version >= 14);
|
||||
// Arc 1a Q#C5 — CompletionPopup gated at v15; a v14 peer
|
||||
// still completes via the daemon-side session + key
|
||||
// round-trip, it just gets no GPU dropdown.
|
||||
let peer_knows_completion_popup = session_registry
|
||||
.session_state(*fid)
|
||||
.is_some_and(|s| s.negotiated_protocol_version >= 15);
|
||||
for msg in &messages {
|
||||
if !peer_knows_status_facts
|
||||
&& matches!(msg, InstanceMessage::StatusFacts { .. })
|
||||
|
|
@ -1086,6 +1097,11 @@ fn dispatcher_loop(
|
|||
{
|
||||
continue;
|
||||
}
|
||||
if !peer_knows_completion_popup
|
||||
&& matches!(msg, InstanceMessage::CompletionPopup { .. })
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// T M10.10 Day 4 / M10.11 F2 — the criterion-1
|
||||
// jitter site: render-write latency.
|
||||
//
|
||||
|
|
@ -1861,6 +1877,13 @@ fn handle_remote_crdt_op(
|
|||
// notify but the op still needs broadcasting (F17).
|
||||
if let Some(edit) = edit_opt.as_ref() {
|
||||
let mut core = editor.core.borrow_mut();
|
||||
// Transient status messages clear on user input. The Key path
|
||||
// gets this from `dispatch_key`'s entry clear; the optimistic
|
||||
// path routes plain typing here instead, and since v15 ships
|
||||
// `core.status` over `StatusFacts`, a stale "12 references"
|
||||
// would otherwise stay wedged in a semantic frontend's band
|
||||
// through ordinary typing.
|
||||
core.status.clear();
|
||||
let post_edit_cursor = edit.range.start + edit.inserted_len;
|
||||
|
||||
// Identify source's active window id (so we can skip it
|
||||
|
|
@ -2326,6 +2349,63 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// v15 regression: an optimistic-path edit (the bulk of plain-char
|
||||
/// typing from a semantic frontend) must clear the transient
|
||||
/// status message, exactly as `dispatch_key`'s entry clear does
|
||||
/// for round-tripped keys — otherwise "12 references" stays
|
||||
/// wedged in the GPU band (which renders `StatusFacts.message`)
|
||||
/// through ordinary typing.
|
||||
#[cfg(feature = "crdt")]
|
||||
#[test]
|
||||
fn handle_remote_crdt_op_clears_the_transient_status() {
|
||||
use crate::editor::EditorState;
|
||||
use crate::protocol::FrontendId;
|
||||
|
||||
let mut editor = EditorState::new();
|
||||
let buffer_id = editor.core.borrow().active_window().buffer_id;
|
||||
{
|
||||
let core = editor.core.borrow();
|
||||
let mut reg = core.registry.borrow_mut();
|
||||
reg.get_mut(buffer_id)
|
||||
.expect("active buffer")
|
||||
.upgrade_to_crdt(2)
|
||||
.expect("upgrade to crdt");
|
||||
}
|
||||
let snapshot_bytes = {
|
||||
let core = editor.core.borrow();
|
||||
let reg = core.registry.borrow();
|
||||
reg.get(buffer_id)
|
||||
.expect("active buffer")
|
||||
.crdt_state()
|
||||
.expect("crdt-backed")
|
||||
.export_snapshot()
|
||||
.expect("export snapshot")
|
||||
};
|
||||
let peer = loro::LoroDoc::new();
|
||||
peer.set_peer_id(99).expect("set peer id");
|
||||
peer.import(&snapshot_bytes).expect("import snapshot");
|
||||
let v_before = peer.oplog_vv();
|
||||
peer.get_text("body").insert(0, "x").expect("peer insert");
|
||||
let op_bytes = peer
|
||||
.export(loro::ExportMode::updates(&v_before))
|
||||
.expect("export op");
|
||||
|
||||
editor.core.borrow_mut().status = "12 references".to_owned();
|
||||
super::handle_remote_crdt_op(
|
||||
&mut editor,
|
||||
FrontendId(99),
|
||||
buffer_id,
|
||||
crate::rope::CrdtOp {
|
||||
peer_id: 99,
|
||||
bytes: op_bytes,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
editor.core.borrow().status.is_empty(),
|
||||
"an optimistic-path edit must clear the transient status"
|
||||
);
|
||||
}
|
||||
|
||||
/// Session B1 regression: a `Key` event from a *semantic*
|
||||
/// (grid-less) frontend must reach the editor core. Before B1 the
|
||||
/// dispatcher's catch-all only called `apply_event` when the
|
||||
|
|
|
|||
|
|
@ -409,6 +409,10 @@ impl Frontend {
|
|||
// toggle; the cell-grid TUI reads its window's mode directly,
|
||||
// so it drops this silently like the other semantic families.
|
||||
| InstanceMessage::LineNumbers { .. }
|
||||
// Arc 1a Q#C5 — CompletionPopup is the semantic-frontend
|
||||
// completion dropdown; the TUI paints the popup via its
|
||||
// CompletionView cell overlay, so it drops this silently.
|
||||
| InstanceMessage::CompletionPopup { .. }
|
||||
| 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_fourteen_for_line_number_modes() {
|
||||
fn protocol_version_is_fifteen_for_completion_popup() {
|
||||
// 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
|
||||
|
|
@ -1705,7 +1705,69 @@ mod tests {
|
|||
// (`InstanceMessage::LineNumbers`, additive + daemon-gated). UX
|
||||
// gutter modes bumped 13→14 (`LineNumbers` swapped `enabled: bool`
|
||||
// for a `LineNumberMode` enum — encoding change, still daemon-gated).
|
||||
assert_eq!(PROTOCOL_VERSION, 14);
|
||||
// Arc 1a Q#C5 bumped 14→15 (`InstanceMessage::CompletionPopup`,
|
||||
// additive + daemon-gated).
|
||||
assert_eq!(PROTOCOL_VERSION, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_facts_round_trip_with_and_without_message() {
|
||||
// v15 widened `StatusFacts` with the transient status message
|
||||
// (its daemon gate moved 8 → 15). Pin both shapes.
|
||||
let bid = crate::buffer::BufferId::next();
|
||||
for message in [None, Some("12 references".to_owned())] {
|
||||
let msg = InstanceMessage::StatusFacts {
|
||||
buffer_id: bid,
|
||||
name: "main.rs".into(),
|
||||
modified: true,
|
||||
diag_errors: 1,
|
||||
diag_warnings: 2,
|
||||
message,
|
||||
};
|
||||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||||
assert_eq!(msg, decoded);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_popup_round_trips_through_postcard() {
|
||||
// Arc 1a Q#C5 (v15): the byte-anchored completion dropdown.
|
||||
// Pin both the open and the closed shapes.
|
||||
let bid = crate::buffer::BufferId::next();
|
||||
for msg in [
|
||||
InstanceMessage::CompletionPopup {
|
||||
buffer_id: bid,
|
||||
anchor: Some(4_096),
|
||||
prefix_len: 2,
|
||||
rows: vec![
|
||||
CompletionPopupRow {
|
||||
label: "hello_world".into(),
|
||||
kind: 3,
|
||||
detail: Some("fn() -> ()".into()),
|
||||
},
|
||||
CompletionPopupRow {
|
||||
label: "help".into(),
|
||||
kind: 14,
|
||||
detail: None,
|
||||
},
|
||||
],
|
||||
selected: Some(1),
|
||||
total: 42,
|
||||
},
|
||||
InstanceMessage::CompletionPopup {
|
||||
buffer_id: bid,
|
||||
anchor: None,
|
||||
prefix_len: 0,
|
||||
rows: Vec::new(),
|
||||
selected: None,
|
||||
total: 0,
|
||||
},
|
||||
] {
|
||||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||||
assert_eq!(msg, decoded);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1714,24 +1776,22 @@ 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 / Q#S1 / Q#SR5 / Q#RX6 / Q#CM1 / Q#MB1 / UX gutter: the
|
||||
// ladder resumes above that floor — v7 (`TripleDown`), v8
|
||||
// (`StatusFacts`), v9 + v10 (`SearchPrompt` + regex/invalid), v11
|
||||
// (the context menu), v12 (the GUI minibuffer), v13 (`LineNumbers`),
|
||||
// v14 (`LineNumberMode`) all interoperate, so v6 through v14 talk.
|
||||
assert!(is_supported_protocol_version(6));
|
||||
assert!(is_supported_protocol_version(7));
|
||||
assert!(is_supported_protocol_version(8));
|
||||
assert!(is_supported_protocol_version(9));
|
||||
assert!(is_supported_protocol_version(10));
|
||||
assert!(is_supported_protocol_version(11));
|
||||
assert!(is_supported_protocol_version(12));
|
||||
assert!(is_supported_protocol_version(13));
|
||||
assert!(is_supported_protocol_version(14));
|
||||
for rejected in [0, 1, 2, 3, 4, 5, 15, u32::MAX] {
|
||||
// Q#M4 / Q#S1 / Q#SR5 / Q#RX6 / Q#CM1 / Q#MB1 / UX gutter /
|
||||
// Arc 1a: the ladder resumes above that floor — v7
|
||||
// (`TripleDown`), v8 (`StatusFacts`), v9 + v10 (`SearchPrompt` +
|
||||
// regex/invalid), v11 (the context menu), v12 (the GUI
|
||||
// minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15
|
||||
// (`CompletionPopup`) all interoperate, so v6 through v15 talk.
|
||||
for accepted in 6..=15 {
|
||||
assert!(
|
||||
is_supported_protocol_version(accepted),
|
||||
"v{accepted} must be accepted"
|
||||
);
|
||||
}
|
||||
for rejected in [0, 1, 2, 3, 4, 5, 16, u32::MAX] {
|
||||
assert!(
|
||||
!is_supported_protocol_version(rejected),
|
||||
"v{rejected} must be rejected by a v14 binary"
|
||||
"v{rejected} must be rejected by a v15 binary"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,17 @@ type MenuPromptFacts = (Vec<MenuPromptRow>, Option<u32>);
|
|||
/// A `None` prompt means the minibuffer is closed.
|
||||
type MinibufferFacts = (Option<String>, String, u32, Vec<String>, Option<u32>, u32);
|
||||
|
||||
/// Cached `CompletionPopup` payload for cached-compare suppression
|
||||
/// (Arc 1a Q#C5): `(anchor, prefix_len, rows-window, selected, total)`.
|
||||
/// A `None` anchor means the popup is closed.
|
||||
type CompletionPopupFacts = (
|
||||
Option<u64>,
|
||||
u32,
|
||||
Vec<crate::protocol::CompletionPopupRow>,
|
||||
Option<u32>,
|
||||
u32,
|
||||
);
|
||||
|
||||
/// How many completion candidates the minibuffer ships per frame — a
|
||||
/// scrolled window around the selection, not the full (≤1024) list.
|
||||
const MB_VISIBLE: usize = 10;
|
||||
|
|
@ -154,9 +165,10 @@ 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)>,
|
||||
/// `(name, modified, diag_errors, diag_warnings, message)` last
|
||||
/// emitted as `StatusFacts` (Q#S1; `message` since v15) —
|
||||
/// cached-compare suppression.
|
||||
last_status: HashMap<BufferId, (String, bool, u32, u32, Option<String>)>,
|
||||
/// Last-emitted line-number gutter mode (UX gutter arc, protocol v14) —
|
||||
/// cached-compare suppression. Seeded to `Some(Off)` (the frontend's
|
||||
/// default) so an off gutter never emits. Per-frontend (one value),
|
||||
|
|
@ -172,6 +184,10 @@ pub struct SemanticRenderState {
|
|||
/// not per-buffer, because the minibuffer is one global core
|
||||
/// instance.
|
||||
last_minibuffer: Option<MinibufferFacts>,
|
||||
/// Last emitted `CompletionPopup` payload per buffer (Arc 1a
|
||||
/// Q#C5), for cached-compare suppression (see
|
||||
/// [`CompletionPopupFacts`]).
|
||||
last_completion_popup: HashMap<BufferId, CompletionPopupFacts>,
|
||||
/// `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)
|
||||
|
|
@ -243,6 +259,7 @@ impl SemanticRenderState {
|
|||
last_search_prompt: HashMap::new(),
|
||||
last_menu_prompt: HashMap::new(),
|
||||
last_minibuffer: None,
|
||||
last_completion_popup: HashMap::new(),
|
||||
last_summary: HashMap::new(),
|
||||
last_status: HashMap::new(),
|
||||
// Seed to the frontend's default (gutter off): a plain default
|
||||
|
|
@ -456,9 +473,90 @@ impl SemanticRenderState {
|
|||
out.extend(self.menu_prompt_msg(state, vp.buffer_id));
|
||||
// --- MinibufferPrompt (Q#MB1, protocol v12) ---
|
||||
out.extend(self.minibuffer_prompt_msg(state, vp.buffer_id));
|
||||
// --- CompletionPopup (Arc 1a Q#C5, protocol v15) ---
|
||||
out.extend(self.completion_popup_msg(state, vp.buffer_id));
|
||||
out
|
||||
}
|
||||
|
||||
/// The `CompletionPopup` message for this frame, or `None` when the
|
||||
/// popup state for `buffer_id` is unchanged (Arc 1a Q#C5). Only the
|
||||
/// active buffer carries a live popup, and — the multi-frontend
|
||||
/// rule — only the frontend whose *own window* owns the session
|
||||
/// sees it open: the session is window-stamped at open
|
||||
/// (`completion_popup_open`), and this producer state is
|
||||
/// per-frontend, so a popup opened by TUI typing never renders in
|
||||
/// an attached GPU and vice versa. Closed = `anchor: None`; first
|
||||
/// sight of a buffer with no popup stays silent (like
|
||||
/// `search_prompt_msg`). The daemon keeps the variant off wires
|
||||
/// negotiated `< 15`.
|
||||
fn completion_popup_msg(
|
||||
&mut self,
|
||||
state: &EditorState,
|
||||
buffer_id: BufferId,
|
||||
) -> Option<InstanceMessage> {
|
||||
let facts: CompletionPopupFacts = {
|
||||
let core = state.core.borrow();
|
||||
if buffer_id != core.active_buffer_id() {
|
||||
return None;
|
||||
}
|
||||
let own_window = core.views.get(&self.frontend_id).map(|v| v.active);
|
||||
let guard = core
|
||||
.completion_popup
|
||||
.lock()
|
||||
.expect("completion popup poisoned");
|
||||
match guard.as_ref() {
|
||||
Some(p)
|
||||
if p.buffer_id == buffer_id
|
||||
&& p.window_id.is_some()
|
||||
&& p.window_id == own_window =>
|
||||
{
|
||||
let (start, len) = crate::completion::popup_window(
|
||||
p.candidates.len(),
|
||||
p.selected,
|
||||
crate::completion::POPUP_MAX_ROWS as usize,
|
||||
);
|
||||
let rows: Vec<crate::protocol::CompletionPopupRow> = p.candidates
|
||||
[start..start + len]
|
||||
.iter()
|
||||
.map(|c| crate::protocol::CompletionPopupRow {
|
||||
label: c.label.clone(),
|
||||
kind: c.kind as u8,
|
||||
detail: c.detail.clone(),
|
||||
})
|
||||
.collect();
|
||||
(
|
||||
Some(p.anchor),
|
||||
u32::try_from(p.prefix.len()).unwrap_or(u32::MAX),
|
||||
rows,
|
||||
u32::try_from(p.selected - start).ok(),
|
||||
u32::try_from(p.total).unwrap_or(u32::MAX),
|
||||
)
|
||||
}
|
||||
_ => (None, 0, Vec::new(), None, 0),
|
||||
}
|
||||
};
|
||||
let cached = self.last_completion_popup.get(&buffer_id);
|
||||
if cached == Some(&facts) {
|
||||
return None;
|
||||
}
|
||||
// First sight of this buffer with no popup: nothing to clear,
|
||||
// stay silent (the search-prompt rule).
|
||||
if cached.is_none() && facts.0.is_none() {
|
||||
self.last_completion_popup.insert(buffer_id, facts);
|
||||
return None;
|
||||
}
|
||||
let msg = InstanceMessage::CompletionPopup {
|
||||
buffer_id,
|
||||
anchor: facts.0,
|
||||
prefix_len: facts.1,
|
||||
rows: facts.2.clone(),
|
||||
selected: facts.3,
|
||||
total: facts.4,
|
||||
};
|
||||
self.last_completion_popup.insert(buffer_id, facts);
|
||||
Some(msg)
|
||||
}
|
||||
|
||||
/// The `SearchPrompt` message for this frame, or `None` when the
|
||||
/// search state for `buffer_id` is unchanged. Only the active
|
||||
/// buffer carries a live prompt: a search shadows dispatch, so it
|
||||
|
|
@ -651,12 +749,17 @@ impl SemanticRenderState {
|
|||
state: &EditorState,
|
||||
buffer_id: BufferId,
|
||||
) -> Option<InstanceMessage> {
|
||||
let (name, modified) = {
|
||||
let (name, modified, message) = {
|
||||
let core = state.core.borrow();
|
||||
// The transient status message (`pmacs.editor.set_status`
|
||||
// — LSP command summaries, error reports). The attached
|
||||
// TUI reads it off the rendered bottom row; a semantic
|
||||
// frontend only sees this wire (v15).
|
||||
let message = (!core.status.is_empty()).then(|| core.status.clone());
|
||||
let registry = core.registry.clone();
|
||||
let reg = registry.borrow();
|
||||
let buf = reg.get(buffer_id).ok()?;
|
||||
(buf.name().to_owned(), buf.is_modified())
|
||||
(buf.name().to_owned(), buf.is_modified(), message)
|
||||
};
|
||||
let counts = {
|
||||
let core = state.core.borrow();
|
||||
|
|
@ -682,7 +785,7 @@ impl SemanticRenderState {
|
|||
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);
|
||||
let facts = (name, modified, diag_errors, diag_warnings, message);
|
||||
if cached == Some(&facts) {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -692,6 +795,7 @@ impl SemanticRenderState {
|
|||
modified: facts.1,
|
||||
diag_errors,
|
||||
diag_warnings,
|
||||
message: facts.4.clone(),
|
||||
};
|
||||
self.last_status.insert(buffer_id, facts);
|
||||
Some(msg)
|
||||
|
|
@ -3491,6 +3595,43 @@ mod tests {
|
|||
assert!(facts_of(&s.render_frame(&state)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_facts_carry_the_transient_message() {
|
||||
// v15: `pmacs.editor.set_status` output must reach semantic
|
||||
// frontends — the "12 references" class of LSP summaries was
|
||||
// TUI-only before (the grid renders the bottom row; the wire
|
||||
// never carried the message).
|
||||
let state = empty_state();
|
||||
let mut s = local();
|
||||
let bid = active_buffer(&state);
|
||||
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
|
||||
let _ = s.render_frame(&state); // baseline facts
|
||||
|
||||
let message_of = |frame: &[InstanceMessage]| {
|
||||
frame.iter().find_map(|m| match m {
|
||||
InstanceMessage::StatusFacts { message, .. } => Some(message.clone()),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
|
||||
state.core.borrow_mut().status = "12 references".to_owned();
|
||||
assert_eq!(
|
||||
message_of(&s.render_frame(&state)),
|
||||
Some(Some("12 references".into())),
|
||||
"a fresh status message re-ships the facts"
|
||||
);
|
||||
// Unchanged → suppressed.
|
||||
assert_eq!(message_of(&s.render_frame(&state)), None);
|
||||
// Cleared → re-ships with None so the frontend's band returns
|
||||
// to the buffer name.
|
||||
state.core.borrow_mut().status.clear();
|
||||
assert_eq!(
|
||||
message_of(&s.render_frame(&state)),
|
||||
Some(None),
|
||||
"clearing the message re-ships the facts"
|
||||
);
|
||||
}
|
||||
|
||||
#[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