From b9bd231e642bcffda13ecd2bc630949ada9c50da Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 30 Jun 2026 21:03:32 -0400 Subject: [PATCH] pmacs GPU minibuffer: wire v12 + band prompt + candidate dropdown (Q#MB1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pmacs-gpu frontend can now render the minibuffer, so M-x, C-x-prefixed commands, and the LSP rename prompt work in the GUI. Render-only — the minibuffer logic already lives in the core, which is untouched (its fields are public, so the producer reads them directly). Protocol v12 (additive; SUPPORTED = [6..12]): - `InstanceMessage::MinibufferPrompt { prompt, input, cursor, candidates, selected, total }` — bufferless (the minibuffer is one global core instance), daemon-gated >= 12. The candidate list ships as a windowed slice (<= MB_VISIBLE = 10) around the selection, so a 1000-command M-x sends ~10 strings per keystroke, not 1000. Producer / daemon / TUI: - `semantic_render::minibuffer_prompt_msg` — cached-compare suppressed (a single value, not per-buffer), emitted from the active-buffer viewport. daemon gates the variant >= 12. The TUI ignores it (it paints the minibuffer via its own bottom row). GPU: - The bottom band shows `prompt + input` (ahead of search/status) with a band caret at the input cursor (monospace advance off the shaped band width); the buffer caret hides while a prompt is open. - A vertical completion dropdown above the band — best match at top, selected row highlighted — via a third `TextRenderer` over bg quads (the menu popup pattern, reusing its colors). Only shows when there are candidates. - `is_minibuffer_open_chord` forwards M-x and the C-x prefix (otherwise withheld) so the GUI can open a prompt / enter a prefix; the daemon then flips `dispatch_idle` false and the intercept gate round-trips the rest. (Also collapsed two unnested_or_patterns clippy nits in the chord helpers.) Tests: candidate windowing, the producer (open M-x via Lua -> prompt + windowed candidates -> cached-compare -> cancel clears), a v12 postcard round-trip, and the version pin. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U --- pmacs-gpu/src/main.rs | 285 +++++++++++++++++++++++++++++++++- pmacs-protocol/src/message.rs | 30 +++- src/daemon.rs | 10 ++ src/frontend.rs | 4 + src/protocol.rs | 68 ++++++-- src/semantic_render.rs | 168 ++++++++++++++++++++ 6 files changed, 549 insertions(+), 16 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 23bc7a1..44dbf2f 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -124,6 +124,17 @@ const MENU_MAX_WIDTH: f32 = 380.0; const MENU_BG: [f32; 4] = [0.16, 0.16, 0.20, 0.98]; const MENU_SELECTED_BG: [f32; 4] = [0.20, 0.40, 0.66, 1.0]; const MENU_SEPARATOR_BG: [f32; 4] = [0.30, 0.30, 0.36, 1.0]; + +// Minibuffer completion dropdown (Q#MB1). A vertical list anchored just +// above the bottom band, best match at the top; reuses the menu popup's +// colors. Width tracks the widest candidate (measured from the shaped +// buffer). +const MB_DROP_ROW_HEIGHT: f32 = 20.0; +const MB_DROP_FONT_SIZE: f32 = 13.0; +const MB_DROP_LINE_HEIGHT: f32 = 20.0; +const MB_DROP_PAD_X: f32 = 10.0; +const MB_DROP_MIN_WIDTH: f32 = 160.0; +const MB_DROP_MAX_WIDTH: f32 = 480.0; const QUAD_SHADER: &str = r" struct VertexOut { @builtin(position) pos: vec4, @@ -555,6 +566,10 @@ struct State { /// the buffer name; the matches highlight via `SearchMatch` /// decorations. search_prompt: Option, + /// Q#MB1 — the live minibuffer (protocol v12), or `None` when + /// closed. The prompt+input render in the bottom band; the + /// candidates (when present) render as a dropdown above it. + minibuffer: Option, /// Q#CM1 — the live context menu (protocol v11), or `None` when /// closed. The rows + highlight come from `MenuPrompt`; the popup /// draws at the pixel of the right-click. @@ -570,6 +585,14 @@ struct State { menu_text_renderer: TextRenderer, /// Popup background / highlight / separator quads (Q#CM1). menu_bg_vertex_buffer: ReusableVertexBuffer, + /// Shaped candidate text for the minibuffer dropdown (Q#MB1), one + /// line per candidate. + mb_buffer: Buffer, + /// Dedicated text renderer for the minibuffer dropdown (its own + /// layer over the buffer, like the menu's). + mb_text_renderer: TextRenderer, + /// Minibuffer dropdown background + selection quads (Q#MB1). + mb_bg_vertex_buffer: ReusableVertexBuffer, /// Minimap vertex bytes cached by [`MinimapCacheKey`] — /// rebuilding rescanned every line shape per frame. minimap_cache: Option<(MinimapCacheKey, Vec)>, @@ -598,6 +621,20 @@ struct SearchPromptLocal { invalid: bool, } +/// The live minibuffer (Q#MB1, protocol v12), mirrored from a +/// `MinibufferPrompt` whose `prompt` was `Some`. The prompt+input draw +/// in the bottom band with a caret; `candidates` (a windowed slice) feed +/// the dropdown. +#[derive(Clone, Debug, PartialEq)] +struct MinibufferLocal { + prompt: String, + input: String, + cursor: u32, + candidates: Vec, + selected: Option, + 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 @@ -828,6 +865,23 @@ impl ApplicationHandler for App { return; } + // Minibuffer-opening chords (Q#MB1): M-x (execute-command) + // and the C-x prefix. Forwarded though otherwise withheld + // so the GUI can open a prompt / enter a prefix; the + // daemon then flips `dispatch_idle` false (minibuffer + // active / pending prefix) and the intercept gate + // round-trips every following key. No optimistic local + // flip (the search-entry precedent). + if is_minibuffer_open_chord(pkey, pmods) { + if let Some(state) = self.state.as_mut() { + state.mark_cursor_stale_after_round_trip(); + } + if let Err(e) = client.send_key(pkey, pmods) { + eprintln!("pmacs-gpu: send_key (minibuffer open) failed: {e}"); + } + return; + } + // Session B2 forwards cursor motion + plain text editing // (Char / Backspace / Enter / Delete / Tab). Ctrl/Alt/ // Meta chords are withheld — they drive commands and @@ -1555,6 +1609,9 @@ impl State { // Q#CM1 — a second renderer so the menu draws as a top layer. let menu_text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); + // Q#MB1 — a third renderer for the minibuffer dropdown layer. + let mb_text_renderer = + TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); let quad_renderer = QuadRenderer::new(&device, surface_format); let squiggle_renderer = SquiggleRenderer::new(&device, surface_format); @@ -1595,6 +1652,15 @@ impl State { Some(MENU_MAX_WIDTH), Some(config.height as f32), ); + let mut mb_buffer = Buffer::new( + &mut font_system, + Metrics::new(MB_DROP_FONT_SIZE, MB_DROP_LINE_HEIGHT), + ); + mb_buffer.set_size( + &mut font_system, + Some(MB_DROP_MAX_WIDTH), + Some(config.height as f32), + ); buffer.set_text( &mut font_system, initial_text, @@ -1667,11 +1733,15 @@ impl State { status_left_text: String::new(), status_facts: None, search_prompt: None, + minibuffer: None, menu: None, menu_anchor_px: (0.0, 0.0), menu_buffer, menu_text_renderer, menu_bg_vertex_buffer: ReusableVertexBuffer::new(), + mb_buffer, + mb_text_renderer, + mb_bg_vertex_buffer: ReusableVertexBuffer::new(), minimap_cache: None, } } @@ -1692,9 +1762,13 @@ impl State { /// every key to the daemon's handler instead of optimistically /// applying it to the buffer. fn daemon_intercepts_keys(&self) -> bool { - // Q#CM1 — an open menu shadows the keymap like search: every key - // round-trips so the daemon's `dispatch_menu_key` drives it. - self.search_prompt.is_some() || self.menu.is_some() || !self.dispatch_idle + // Q#CM1 / Q#MB1 — an open menu or minibuffer shadows the keymap + // like search: every key round-trips so the daemon's + // `dispatch_menu_key` / minibuffer handler drives it. + self.search_prompt.is_some() + || self.menu.is_some() + || self.minibuffer.is_some() + || !self.dispatch_idle } /// Shared eligibility gates for the optimistic edit paths @@ -2474,6 +2548,27 @@ impl State { self.window.request_redraw(); None } + // Q#MB1 — the minibuffer prompt/input/candidates. `prompt: + // None` closes it. + InstanceMessage::MinibufferPrompt { + prompt, + input, + cursor, + candidates, + selected, + total, + } => { + self.minibuffer = prompt.map(|prompt| MinibufferLocal { + prompt, + input, + cursor, + candidates, + selected, + total, + }); + self.window.request_redraw(); + None + } _ => None, } } @@ -2897,6 +2992,12 @@ impl State { /// over the band like Emacs's echo area, returning to the buffer /// name + modified dot (v8 `StatusFacts`) when the search ends. fn compose_status_left(&self) -> String { + // Q#MB1 — an open minibuffer takes over the band: prompt + input + // (the candidates render separately as a dropdown). Measured by + // the band caret, so it must stay exactly `prompt + input`. + if let Some(mb) = self.minibuffer.as_ref() { + return format!("{}{}", mb.prompt, mb.input); + } if let Some(sp) = self .search_prompt .as_ref() @@ -3052,6 +3153,75 @@ impl State { rects_to_vertex_bytes(&rects, self.config.width, self.config.height) } + /// Re-shape the minibuffer dropdown candidates (Q#MB1), one line per + /// candidate, best match first. Empty when there are no candidates. + fn refresh_mb_buffer(&mut self) { + let text = self + .minibuffer + .as_ref() + .map_or_else(String::new, |mb| mb.candidates.join("\n")); + self.mb_buffer.set_text( + &mut self.font_system, + &text, + &Attrs::new().family(Family::Name("JetBrains Mono")), + Shaping::Advanced, + None, + ); + self.mb_buffer + .shape_until_scroll(&mut self.font_system, false); + } + + /// Dropdown geometry `(left, top_y, width)` when the minibuffer has + /// candidates: a list anchored just above the bottom band, growing + /// upward, as wide as the widest candidate (clamped). `None` when + /// closed or candidate-free. `refresh_mb_buffer` must have run so the + /// width measurement is current. + fn mb_dropdown_rect(&self) -> Option<(f32, f32, f32)> { + let mb = self.minibuffer.as_ref()?; + let n = mb.candidates.len(); + if n == 0 { + return None; + } + let widest = self + .mb_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 band_top = text_area_bottom(self.config.height); + let top_y = band_top - n as f32 * MB_DROP_ROW_HEIGHT; + Some((STATUS_TEXT_PAD, top_y, width)) + } + + /// Minibuffer dropdown background + selection-highlight quads (Q#MB1). + /// Empty when closed / candidate-free. + fn mb_dropdown_vertex_bytes(&self) -> Vec { + let Some(mb) = self.minibuffer.as_ref() else { + return Vec::new(); + }; + let Some((x, top_y, width)) = self.mb_dropdown_rect() else { + return Vec::new(); + }; + let n = mb.candidates.len(); + let mut rects = vec![MinimapRect { + x, + y: top_y, + w: width, + h: n as f32 * MB_DROP_ROW_HEIGHT, + color: MENU_BG, + }]; + if let Some(sel) = mb.selected { + rects.push(MinimapRect { + x, + y: top_y + sel 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 @@ -3392,6 +3562,21 @@ impl State { &menu_vertices, ) .cloned(); + // Q#MB1 — the minibuffer dropdown quads (bg + selection), a top + // layer above the band. `refresh_mb_buffer` first so the width + // measurement in `mb_dropdown_vertex_bytes` is current. + self.refresh_mb_buffer(); + let mb_vertices = self.mb_dropdown_vertex_bytes(); + let mb_vertex_count = (mb_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; + let mb_bg_buffer = self + .mb_bg_vertex_buffer + .upload( + &self.device, + &self.queue, + "pmacs-gpu minibuffer dropdown", + &mb_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(); @@ -3570,6 +3755,37 @@ impl State { ) .expect("menu text_renderer prepare"); + // Q#MB1 — prepare the minibuffer dropdown glyphs in their layer. + let mb_areas: Vec