diff --git a/docs/in-buffer-completion-framing.md b/docs/in-buffer-completion-framing.md index d667df7..193d5d2 100644 --- a/docs/in-buffer-completion-framing.md +++ b/docs/in-buffer-completion-framing.md @@ -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). diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 711cfba..d9955ff 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -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, + /// 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)>, @@ -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, } /// 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, + /// Highlighted row within `rows`. + selected: Option, + /// 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 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 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::>() + .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 { + 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