From dc26c84b7cfa2b82f22ed314f52812bd6df436be Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 7 Jul 2026 17:10:31 -0400 Subject: [PATCH 1/5] feat(protocol): v15 CompletionPopup message + producer + daemon gate (Q#C5) InstanceMessage::CompletionPopup {buffer_id, anchor: Option, prefix_len, rows: Vec, selected, total} -- the first byte-anchored popup on the wire: the frontend maps byte -> glyph rect locally (the caret precedent), so the instance never learns a pixel. Rows are display-only; accept resolves daemon-side via dispatch_completion_key, so insert text never ships. PROTOCOL_VERSION 14 -> 15, SUPPORTED extended; postcard round-trip (open + closed shapes) and version-pin/ladder tests updated. Producer: semantic_render::completion_popup_msg, the family pattern (per-buffer cached-compare, active-buffer only, first-sight-closed stays silent) with one new rule -- the session is WINDOW-stamped and this state is per-frontend, so only the frontend whose own window owns the session sees it open: a popup opened by TUI typing never renders in an attached GPU and vice versa. Windowed rows share the TUI overlay's POPUP_MAX_ROWS. Daemon-gated >= 15 (a v14 peer still completes via the key round-trip, it just gets no GPU dropdown). GPU consumption follows in this branch. Co-Authored-By: Claude Fable 5 --- pmacs-gpu/src/main.rs | 1 + pmacs-protocol/src/lib.rs | 12 ++--- pmacs-protocol/src/message.rs | 55 +++++++++++++++++++- src/completion.rs | 5 +- src/daemon.rs | 11 ++++ src/frontend.rs | 4 ++ src/protocol.rs | 76 ++++++++++++++++++++------- src/semantic_render.rs | 97 +++++++++++++++++++++++++++++++++++ 8 files changed, 233 insertions(+), 28 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 711cfba..a4c037f 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -5201,6 +5201,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str { InstanceMessage::ResourceOffer { .. } => "ResourceOffer", InstanceMessage::DispatchIdle { .. } => "DispatchIdle", InstanceMessage::LineNumbers { .. } => "LineNumbers", + InstanceMessage::CompletionPopup { .. } => "CompletionPopup", } } diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index fec34e3..bcb1000 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -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}; diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 7ec8eb8..c9cacef 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -922,6 +922,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, + /// 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, + /// Highlighted row *within* `rows`, or `None`. + selected: Option, + /// 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 +1002,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, +} + /// Flat selection state for the wire. /// /// Mirrors [`crate::window::Selection`] but as a self-contained pair @@ -1252,7 +1293,14 @@ 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. +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 +1357,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`]. diff --git a/src/completion.rs b/src/completion.rs index ee152e8..d7c4492 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -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; diff --git a/src/daemon.rs b/src/daemon.rs index 8efa647..0433a32 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1056,6 +1056,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 +1092,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. // diff --git a/src/frontend.rs b/src/frontend.rs index 7ed5cc8..2595ebd 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -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 diff --git a/src/protocol.rs b/src/protocol.rs index eaa0610..ea84848 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -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,49 @@ 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 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 +1756,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" ); } } diff --git a/src/semantic_render.rs b/src/semantic_render.rs index b99e331..c2a7abb 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -91,6 +91,17 @@ type MenuPromptFacts = (Vec, Option); /// A `None` prompt means the minibuffer is closed. type MinibufferFacts = (Option, String, u32, Vec, Option, 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, + u32, + Vec, + Option, + 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; @@ -172,6 +183,10 @@ pub struct SemanticRenderState { /// not per-buffer, because the minibuffer is one global core /// instance. last_minibuffer: Option, + /// Last emitted `CompletionPopup` payload per buffer (Arc 1a + /// Q#C5), for cached-compare suppression (see + /// [`CompletionPopupFacts`]). + last_completion_popup: HashMap, /// `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 +258,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 +472,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 { + 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 = 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 From d20a97ca7b4202556f03008714f9c831078ab881 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 7 Jul 2026 17:21:47 -0400 Subject: [PATCH 2/5] feat(gpu): byte-anchored completion dropdown + control-key routing (Q#C5/Q#C6) CompletionLocal mirrors the v15 wire (anchor byte, windowed rows, selection); a close always applies even for a switched-away buffer (dropping it would wedge a stale popup), while opens follow the CrdtOp current-buffer rule. Rendering is a dedicated dropdown layer (fourth TextRenderer + quad batch, the mb_dropdown_* shape): the anchor byte maps to its glyph rect via the caret walk, rows draw below the anchor line growing toward the band (flipping above when nothing fits), width clamps to the window with the left edge shifted back from the right margin, and the visible slice windows around the selection (F-007 discipline). Kind glyphs replicate the TUI popup's mapping. Key routing (Q#C6) turned out narrower than framed: C-n/C-p/C-g already round-trip as command chords and Up/Down as forwarded motion keys, so only two defaults are wrong under a popup and get gated on completion_open -- Esc (dismisses via round-trip instead of the local quit) and RET/TAB (skip the optimistic insert so they accept via dispatch_completion_key instead of typing a newline/tab). Typing stays fully optimistic; the daemon's after-edit refresh re-ships the popup. Co-Authored-By: Claude Fable 5 --- pmacs-gpu/src/main.rs | 385 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 374 insertions(+), 11 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index a4c037f..fbddf90 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,6 +679,29 @@ struct State { gutter_text_renderer: TextRenderer, } +/// 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), /// mirrored from `InstanceMessage::StatusFacts`. #[derive(Clone, Debug, PartialEq, Eq)] @@ -705,6 +739,34 @@ 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 { + /// 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 +911,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| state.completion.is_some()); + // 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 +1034,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 +1832,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 +1887,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 +1990,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, @@ -2728,6 +2834,38 @@ 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 { + anchor, + prefix_len, + rows, + selected, + total, + }); + self.request_redraw(); + None + } _ => None, } } @@ -3510,6 +3648,167 @@ 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)> { + 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 +4287,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 +4560,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