pmacs GPU minibuffer: wire v12 + band prompt + candidate dropdown (Q#MB1)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
This commit is contained in:
Levi Neuwirth 2026-06-30 21:03:32 -04:00
parent a6070e201d
commit b9bd231e64
6 changed files with 549 additions and 16 deletions

View File

@ -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_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_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]; 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" const QUAD_SHADER: &str = r"
struct VertexOut { struct VertexOut {
@builtin(position) pos: vec4<f32>, @builtin(position) pos: vec4<f32>,
@ -555,6 +566,10 @@ struct State {
/// the buffer name; the matches highlight via `SearchMatch` /// the buffer name; the matches highlight via `SearchMatch`
/// decorations. /// decorations.
search_prompt: Option<SearchPromptLocal>, search_prompt: Option<SearchPromptLocal>,
/// 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<MinibufferLocal>,
/// Q#CM1 — the live context menu (protocol v11), or `None` when /// Q#CM1 — the live context menu (protocol v11), or `None` when
/// closed. The rows + highlight come from `MenuPrompt`; the popup /// closed. The rows + highlight come from `MenuPrompt`; the popup
/// draws at the pixel of the right-click. /// draws at the pixel of the right-click.
@ -570,6 +585,14 @@ struct State {
menu_text_renderer: TextRenderer, menu_text_renderer: TextRenderer,
/// Popup background / highlight / separator quads (Q#CM1). /// Popup background / highlight / separator quads (Q#CM1).
menu_bg_vertex_buffer: ReusableVertexBuffer, 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`] — /// Minimap vertex bytes cached by [`MinimapCacheKey`] —
/// rebuilding rescanned every line shape per frame. /// rebuilding rescanned every line shape per frame.
minimap_cache: Option<(MinimapCacheKey, Vec<u8>)>, minimap_cache: Option<(MinimapCacheKey, Vec<u8>)>,
@ -598,6 +621,20 @@ struct SearchPromptLocal {
invalid: bool, 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<String>,
selected: Option<u32>,
total: u32,
}
/// The live context menu (Q#CM1, protocol v11), mirrored from a /// The live context menu (Q#CM1, protocol v11), mirrored from a
/// `MenuPrompt` with non-empty rows. The popup draws at `anchor_px` /// `MenuPrompt` with non-empty rows. The popup draws at `anchor_px`
/// (the right-click pixel, remembered locally — the daemon never sees /// (the right-click pixel, remembered locally — the daemon never sees
@ -828,6 +865,23 @@ impl ApplicationHandler<AppEvent> for App {
return; 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 // Session B2 forwards cursor motion + plain text editing
// (Char / Backspace / Enter / Delete / Tab). Ctrl/Alt/ // (Char / Backspace / Enter / Delete / Tab). Ctrl/Alt/
// Meta chords are withheld — they drive commands and // 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. // Q#CM1 — a second renderer so the menu draws as a top layer.
let menu_text_renderer = let menu_text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); 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 quad_renderer = QuadRenderer::new(&device, surface_format);
let squiggle_renderer = SquiggleRenderer::new(&device, surface_format); let squiggle_renderer = SquiggleRenderer::new(&device, surface_format);
@ -1595,6 +1652,15 @@ impl State {
Some(MENU_MAX_WIDTH), Some(MENU_MAX_WIDTH),
Some(config.height as f32), 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( buffer.set_text(
&mut font_system, &mut font_system,
initial_text, initial_text,
@ -1667,11 +1733,15 @@ impl State {
status_left_text: String::new(), status_left_text: String::new(),
status_facts: None, status_facts: None,
search_prompt: None, search_prompt: None,
minibuffer: None,
menu: None, menu: None,
menu_anchor_px: (0.0, 0.0), menu_anchor_px: (0.0, 0.0),
menu_buffer, menu_buffer,
menu_text_renderer, menu_text_renderer,
menu_bg_vertex_buffer: ReusableVertexBuffer::new(), menu_bg_vertex_buffer: ReusableVertexBuffer::new(),
mb_buffer,
mb_text_renderer,
mb_bg_vertex_buffer: ReusableVertexBuffer::new(),
minimap_cache: None, minimap_cache: None,
} }
} }
@ -1692,9 +1762,13 @@ impl State {
/// every key to the daemon's handler instead of optimistically /// every key to the daemon's handler instead of optimistically
/// applying it to the buffer. /// applying it to the buffer.
fn daemon_intercepts_keys(&self) -> bool { fn daemon_intercepts_keys(&self) -> bool {
// Q#CM1 — an open menu shadows the keymap like search: every key // Q#CM1 / Q#MB1 — an open menu or minibuffer shadows the keymap
// round-trips so the daemon's `dispatch_menu_key` drives it. // like search: every key round-trips so the daemon's
self.search_prompt.is_some() || self.menu.is_some() || !self.dispatch_idle // `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 /// Shared eligibility gates for the optimistic edit paths
@ -2474,6 +2548,27 @@ impl State {
self.window.request_redraw(); self.window.request_redraw();
None 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, _ => None,
} }
} }
@ -2897,6 +2992,12 @@ impl State {
/// over the band like Emacs's echo area, returning to the buffer /// over the band like Emacs's echo area, returning to the buffer
/// name + modified dot (v8 `StatusFacts`) when the search ends. /// name + modified dot (v8 `StatusFacts`) when the search ends.
fn compose_status_left(&self) -> String { 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 if let Some(sp) = self
.search_prompt .search_prompt
.as_ref() .as_ref()
@ -3052,6 +3153,75 @@ impl State {
rects_to_vertex_bytes(&rects, self.config.width, self.config.height) 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<u8> {
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 /// Bookkeeping for an outgoing Pointer event: it supersedes any
/// unconfirmed optimistic-cursor prediction (the daemon's answer /// unconfirmed optimistic-cursor prediction (the daemon's answer
/// will be the click position, not the typing prediction), and /// will be the click position, not the typing prediction), and
@ -3392,6 +3562,21 @@ impl State {
&menu_vertices, &menu_vertices,
) )
.cloned(); .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's strip rides the bg quad batch so it draws under
// the band text (text renders after the first quad draw). // the band text (text renders after the first quad draw).
let mut bg_vertices = self.decoration_background_vertex_bytes(); let mut bg_vertices = self.decoration_background_vertex_bytes();
@ -3570,6 +3755,37 @@ impl State {
) )
.expect("menu text_renderer prepare"); .expect("menu text_renderer prepare");
// Q#MB1 — prepare the minibuffer dropdown glyphs in their layer.
let mb_areas: Vec<TextArea> = self
.mb_dropdown_rect()
.map(|(x, top_y, width)| TextArea {
buffer: &self.mb_buffer,
left: x + MB_DROP_PAD_X,
top: top_y,
scale: 1.0,
bounds: TextBounds {
left: x as i32,
top: top_y as i32,
right: (x + width).round() as i32,
bottom: text_area_bottom(self.config.height).round() as i32,
},
default_color: Color::rgb(232, 232, 238),
custom_glyphs: &[],
})
.into_iter()
.collect();
self.mb_text_renderer
.prepare(
&self.device,
&self.queue,
&mut self.font_system,
&mut self.atlas,
&self.viewport,
mb_areas,
&mut self.swash_cache,
)
.expect("minibuffer text_renderer prepare");
let mut encoder = self let mut encoder = self
.device .device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { .create_command_encoder(&wgpu::CommandEncoderDescriptor {
@ -3619,6 +3835,15 @@ impl State {
self.quad_renderer self.quad_renderer
.render(&mut pass, vertex_buffer, minimap_vertex_count); .render(&mut pass, vertex_buffer, minimap_vertex_count);
} }
// Q#MB1 — the minibuffer dropdown draws above the band: bg +
// selection quads, then its candidate glyphs on top.
if let Some(vertex_buffer) = mb_bg_buffer.as_ref() {
self.quad_renderer
.render(&mut pass, vertex_buffer, mb_vertex_count);
}
self.mb_text_renderer
.render(&self.atlas, &self.viewport, &mut pass)
.expect("minibuffer text_renderer render");
// Q#CM1 — the context menu draws last: its bg/highlight quads // Q#CM1 — the context menu draws last: its bg/highlight quads
// occlude everything beneath, then its glyphs on top. // occlude everything beneath, then its glyphs on top.
if let Some(vertex_buffer) = menu_bg_buffer.as_ref() { if let Some(vertex_buffer) = menu_bg_buffer.as_ref() {
@ -3816,6 +4041,14 @@ impl State {
/// Empty when no own cursor is known, it's in another buffer, or it /// Empty when no own cursor is known, it's in another buffer, or it
/// is scrolled out of the visible slice. /// is scrolled out of the visible slice.
fn caret_vertex_bytes(&self) -> Vec<u8> { fn caret_vertex_bytes(&self) -> Vec<u8> {
// Q#MB1 — while the minibuffer is open the caret lives in the
// band at the input cursor, not in the buffer.
if self.minibuffer.is_some() {
return self
.minibuffer_caret_rect()
.map(|r| rects_to_vertex_bytes(&[r], self.config.width, self.config.height))
.unwrap_or_default();
}
let (vstart, vend) = self.view_range; let (vstart, vend) = self.view_range;
if vend <= vstart { if vend <= vstart {
return Vec::new(); return Vec::new();
@ -3828,6 +4061,36 @@ impl State {
rects_to_vertex_bytes(&[rect], self.config.width, self.config.height) rects_to_vertex_bytes(&[rect], self.config.width, self.config.height)
} }
/// The caret rectangle for an open minibuffer (Q#MB1): a thin bar in
/// the bottom band at the input cursor. The band font is monospace,
/// so the per-char advance is the shaped status-left width divided by
/// its char count; the caret sits `prompt_chars + cursor` advances
/// from the band's left pad.
fn minibuffer_caret_rect(&self) -> Option<MinimapRect> {
let mb = self.minibuffer.as_ref()?;
let line_w = self
.status_left_buffer
.layout_runs()
.map(|r| r.line_w)
.fold(0.0_f32, f32::max);
let chars = mb.prompt.chars().count() + mb.input.chars().count();
let advance = if chars > 0 {
line_w / chars as f32
} else {
0.0
};
let cursor_chars = mb.prompt.chars().count() as f32 + mb.cursor as f32;
let status_top =
text_area_bottom(self.config.height) + (STATUS_BAND_HEIGHT - STATUS_LINE_HEIGHT) / 2.0;
Some(MinimapRect {
x: STATUS_TEXT_PAD + advance * cursor_chars,
y: status_top,
w: CARET_WIDTH,
h: STATUS_LINE_HEIGHT,
color: CARET_COLOR,
})
}
/// The caret rectangle for the own cursor, in slice coordinates: a /// The caret rectangle for the own cursor, in slice coordinates: a
/// thin bar at the left edge of the glyph the cursor sits before (or /// thin bar at the left edge of the glyph the cursor sits before (or
/// the right edge of the last glyph at line end). `None` when the /// the right edge of the last glyph at line end). `None` when the
@ -4413,6 +4676,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
InstanceMessage::StatusFacts { .. } => "StatusFacts", InstanceMessage::StatusFacts { .. } => "StatusFacts",
InstanceMessage::SearchPrompt { .. } => "SearchPrompt", InstanceMessage::SearchPrompt { .. } => "SearchPrompt",
InstanceMessage::MenuPrompt { .. } => "MenuPrompt", InstanceMessage::MenuPrompt { .. } => "MenuPrompt",
InstanceMessage::MinibufferPrompt { .. } => "MinibufferPrompt",
InstanceMessage::BlockAdornments { .. } => "BlockAdornments", InstanceMessage::BlockAdornments { .. } => "BlockAdornments",
InstanceMessage::FoldState { .. } => "FoldState", InstanceMessage::FoldState { .. } => "FoldState",
InstanceMessage::ResourceOffer { .. } => "ResourceOffer", InstanceMessage::ResourceOffer { .. } => "ResourceOffer",
@ -4547,12 +4811,23 @@ fn is_search_entry_chord(key: ProtocolKey, mods: Modifiers) -> bool {
fn is_clipboard_chord(key: ProtocolKey, mods: Modifiers) -> bool { fn is_clipboard_chord(key: ProtocolKey, mods: Modifiers) -> bool {
matches!( matches!(
(key, mods), (key, mods),
(ProtocolKey::Char('w'), Modifiers::ALT) (ProtocolKey::Char('w'), Modifiers::ALT | Modifiers::CTRL)
| (ProtocolKey::Char('w'), Modifiers::CTRL)
| (ProtocolKey::Char('y'), Modifiers::CTRL) | (ProtocolKey::Char('y'), Modifiers::CTRL)
) )
} }
/// The chords that open a minibuffer prompt or enter a prefix (Q#MB1):
/// `M-x` (execute-command) and the `C-x` prefix. Forwarded though
/// otherwise withheld; once the daemon enters the minibuffer / a pending
/// prefix, `dispatch_idle` goes false and the intercept gate round-trips
/// the rest. General Emacs-chord forwarding stays a separate thread.
fn is_minibuffer_open_chord(key: ProtocolKey, mods: Modifiers) -> bool {
matches!(
(key, mods),
(ProtocolKey::Char('x'), Modifiers::ALT | Modifiers::CTRL)
)
}
fn is_plain_text_modifiers(mods: Modifiers) -> bool { fn is_plain_text_modifiers(mods: Modifiers) -> bool {
!mods.contains(Modifiers::CTRL) !mods.contains(Modifiers::CTRL)
&& !mods.contains(Modifiers::ALT) && !mods.contains(Modifiers::ALT)

View File

@ -883,6 +883,28 @@ pub enum InstanceMessage {
/// closed. /// closed.
active: Option<u32>, active: Option<u32>,
}, },
/// Minibuffer prompt state for a semantic frontend (Q#MB1, protocol
/// v12). The minibuffer is a single *global* core instance, so this
/// is bufferless; the producer still emits it from the active-buffer
/// viewport. `prompt: None` clears the GUI. Cached-compare
/// suppressed like `SearchPrompt`; daemon-gated `>= 12`.
MinibufferPrompt {
/// The prompt string (e.g. `"M-x "`), or `None` when no
/// minibuffer is open.
prompt: Option<String>,
/// The text typed so far.
input: String,
/// Codepoints before the cursor within `input` (the caret
/// position).
cursor: u32,
/// A windowed slice of the completion candidates (best-first,
/// already filtered/sorted by the core), `<= MB_VISIBLE`.
candidates: Vec<String>,
/// Highlighted row *within* `candidates`, or `None`.
selected: Option<u32>,
/// Total candidate count (the window is a slice of this).
total: u32,
},
} }
/// One row of an open menu on the wire ([`InstanceMessage::MenuPrompt`]). /// One row of an open menu on the wire ([`InstanceMessage::MenuPrompt`]).
@ -1158,7 +1180,7 @@ pub enum ResourceBody {
/// encoding. Still daemon-gated per session (now at `< 10`); a v9 peer /// encoding. Still daemon-gated per session (now at `< 10`); a v9 peer
/// negotiates v9 and simply receives no `SearchPrompt` (the decorations /// negotiates v9 and simply receives no `SearchPrompt` (the decorations
/// still highlight), rather than mis-decoding the wider shape. /// still highlight), rather than mis-decoding the wider shape.
pub const PROTOCOL_VERSION: u32 = 11; pub const PROTOCOL_VERSION: u32 = 12;
/// T M10.5: the set of protocol versions a v1.0 binary accepts on /// 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 /// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
@ -1211,7 +1233,11 @@ pub const PROTOCOL_VERSION: u32 = 11;
/// `PointerKind::Context` (frontend-gated like `Pointer`/`TripleDown`), /// `PointerKind::Context` (frontend-gated like `Pointer`/`TripleDown`),
/// `FrontendEvent::MenuPointer`, and `InstanceMessage::MenuPrompt` /// `FrontendEvent::MenuPointer`, and `InstanceMessage::MenuPrompt`
/// (daemon-gated per session) — all additive, so the ladder resumes. /// (daemon-gated per session) — all additive, so the ladder resumes.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11]; ///
/// 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];
/// T M10.5: predicate for the handshake check. Returns `true` if /// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. /// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].

View File

@ -1047,6 +1047,9 @@ fn dispatcher_loop(
let peer_knows_menu_prompt = session_registry let peer_knows_menu_prompt = session_registry
.session_state(*fid) .session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 11); .is_some_and(|s| s.negotiated_protocol_version >= 11);
let peer_knows_minibuffer_prompt = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 12);
for msg in &messages { for msg in &messages {
if !peer_knows_status_facts if !peer_knows_status_facts
&& matches!(msg, InstanceMessage::StatusFacts { .. }) && matches!(msg, InstanceMessage::StatusFacts { .. })
@ -1065,6 +1068,13 @@ fn dispatcher_loop(
{ {
continue; continue;
} }
// Q#MB1 — MinibufferPrompt gated at v12; a v11 peer
// simply can't render the GUI minibuffer.
if !peer_knows_minibuffer_prompt
&& matches!(msg, InstanceMessage::MinibufferPrompt { .. })
{
continue;
}
// T M10.10 Day 4 / M10.11 F2 — the criterion-1 // T M10.10 Day 4 / M10.11 F2 — the criterion-1
// jitter site: render-write latency. // jitter site: render-write latency.
// //

View File

@ -401,6 +401,10 @@ impl Frontend {
// the TUI renders the menu via its cell overlay instead, so // the TUI renders the menu via its cell overlay instead, so
// it drops this silently like the other semantic families. // it drops this silently like the other semantic families.
| InstanceMessage::MenuPrompt { .. } | InstanceMessage::MenuPrompt { .. }
// Q#MB1 — MinibufferPrompt is the semantic-frontend minibuffer
// surface; the TUI paints the minibuffer via its own bottom
// row, so it drops this silently too.
| InstanceMessage::MinibufferPrompt { .. }
| InstanceMessage::ResourceOffer { .. } | InstanceMessage::ResourceOffer { .. }
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s // T M11.6 — DispatchIdle is consumed by `attach.rs`'s
// optimistic-apply gate; if any reaches this render path // optimistic-apply gate; if any reaches this render path

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips --- // --- M5.5a handshake & postcard round-trips ---
#[test] #[test]
fn protocol_version_is_eleven_for_context_menu() { fn protocol_version_is_twelve_for_minibuffer() {
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp / // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). T M11.6 // SemanticFrame family + FrontendEvent::Viewport). T M11.6
@ -1699,8 +1699,10 @@ mod tests {
// Q#RX6 bumped 9→10 (`SearchPrompt` gained regex/invalid; // Q#RX6 bumped 9→10 (`SearchPrompt` gained regex/invalid;
// encoding change to that variant, still daemon-gated). Q#CM1 // encoding change to that variant, still daemon-gated). Q#CM1
// bumped 10→11 (`PointerKind::Context` + `MenuPointer` + // bumped 10→11 (`PointerKind::Context` + `MenuPointer` +
// `MenuPrompt`, all additive; the message daemon-gated). // `MenuPrompt`, all additive; the message daemon-gated). Q#MB1
assert_eq!(PROTOCOL_VERSION, 11); // bumped 11→12 (`InstanceMessage::MinibufferPrompt`, additive +
// daemon-gated).
assert_eq!(PROTOCOL_VERSION, 12);
} }
#[test] #[test]
@ -1709,20 +1711,21 @@ mod tests {
// every cell-carrying message, ending the v1v5 ladder — // every cell-carrying message, ending the v1v5 ladder —
// pre-v6 peers are refused at the handshake (a clean // pre-v6 peers are refused at the handshake (a clean
// VersionMismatch) rather than garbling postcard mid-session. // VersionMismatch) rather than garbling postcard mid-session.
// Q#M4 / Q#S1 / Q#SR5 / Q#RX6 / Q#CM1: the ladder resumes above // Q#M4 / Q#S1 / Q#SR5 / Q#RX6 / Q#CM1 / Q#MB1: the ladder resumes
// that floor — v7 (`TripleDown`), v8 (`StatusFacts`), v9 + v10 // above that floor — v7 (`TripleDown`), v8 (`StatusFacts`), v9 +
// (`SearchPrompt` + regex/invalid), v11 (the context menu) all // v10 (`SearchPrompt` + regex/invalid), v11 (the context menu),
// interoperate, so v6 through v11 talk. // v12 (the GUI minibuffer) all interoperate, so v6 through v12 talk.
assert!(is_supported_protocol_version(6)); assert!(is_supported_protocol_version(6));
assert!(is_supported_protocol_version(7)); assert!(is_supported_protocol_version(7));
assert!(is_supported_protocol_version(8)); assert!(is_supported_protocol_version(8));
assert!(is_supported_protocol_version(9)); assert!(is_supported_protocol_version(9));
assert!(is_supported_protocol_version(10)); assert!(is_supported_protocol_version(10));
assert!(is_supported_protocol_version(11)); assert!(is_supported_protocol_version(11));
for rejected in [0, 1, 2, 3, 4, 5, 12, u32::MAX] { assert!(is_supported_protocol_version(12));
for rejected in [0, 1, 2, 3, 4, 5, 13, u32::MAX] {
assert!( assert!(
!is_supported_protocol_version(rejected), !is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v11 binary" "v{rejected} must be rejected by a v12 binary"
); );
} }
} }
@ -1929,6 +1932,53 @@ mod tests {
} }
} }
#[test]
fn minibuffer_prompt_round_trips_through_postcard() {
// Q#MB1 — the v12 wire variant. Cover an open prompt with a
// windowed candidate list + selection, and a cleared band.
let cases = [
(
Some("M-x ".to_owned()),
"ed".to_owned(),
2u32,
vec!["edit.copy".to_owned(), "edit.cut".to_owned()],
Some(1u32),
7u32,
),
(None, String::new(), 0, Vec::new(), None, 0),
];
for (prompt, input, cursor, candidates, selected, total) in cases {
let msg = InstanceMessage::MinibufferPrompt {
prompt: prompt.clone(),
input: input.clone(),
cursor,
candidates: candidates.clone(),
selected,
total,
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
match decoded {
InstanceMessage::MinibufferPrompt {
prompt: got_prompt,
input: got_input,
cursor: got_cursor,
candidates: got_candidates,
selected: got_selected,
total: got_total,
} => {
assert_eq!(got_prompt, prompt);
assert_eq!(got_input, input);
assert_eq!(got_cursor, cursor);
assert_eq!(got_candidates, candidates);
assert_eq!(got_selected, selected);
assert_eq!(got_total, total);
}
other => panic!("expected MinibufferPrompt, got {other:?}"),
}
}
}
#[test] #[test]
fn key_event_to_crossterm_round_trips() { fn key_event_to_crossterm_round_trips() {
// Build a protocol KeyEvent, translate to crossterm, translate // Build a protocol KeyEvent, translate to crossterm, translate

View File

@ -86,6 +86,32 @@ type SearchPromptFacts = (Option<String>, Option<u32>, u32, bool, bool);
/// menu. /// menu.
type MenuPromptFacts = (Vec<MenuPromptRow>, Option<u32>); type MenuPromptFacts = (Vec<MenuPromptRow>, Option<u32>);
/// Cached `MinibufferPrompt` payload for cached-compare suppression
/// (Q#MB1): `(prompt, input, cursor, candidates-window, selected, total)`.
/// A `None` prompt means the minibuffer is closed.
type MinibufferFacts = (Option<String>, String, u32, Vec<String>, 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;
/// A window of up to [`MB_VISIBLE`] candidates around `selected`, plus
/// the selection's index *within* that window. Keeps the selected row
/// visible as the user cycles a long list.
fn minibuffer_window(candidates: &[String], selected: Option<usize>) -> (Vec<String>, Option<u32>) {
if candidates.is_empty() {
return (Vec::new(), None);
}
let sel = selected.unwrap_or(0).min(candidates.len() - 1);
let start = sel
.saturating_sub(MB_VISIBLE / 2)
.min(candidates.len().saturating_sub(MB_VISIBLE));
let end = (start + MB_VISIBLE).min(candidates.len());
let window = candidates[start..end].to_vec();
let selected_in_window = selected.map(|s| (s - start) as u32);
(window, selected_in_window)
}
/// Owns one `semantic_render` session's projection state: the last /// Owns one `semantic_render` session's projection state: the last
/// viewport the frontend declared, and the diff baseline per buffer /// viewport the frontend declared, and the diff baseline per buffer
/// for the `StyleSpans` and `Decorations` families. /// for the `StyleSpans` and `Decorations` families.
@ -137,6 +163,10 @@ pub struct SemanticRenderState {
/// Last emitted `MenuPrompt` payload per buffer (Q#CM1), for /// Last emitted `MenuPrompt` payload per buffer (Q#CM1), for
/// cached-compare suppression (see [`MenuPromptFacts`]). /// cached-compare suppression (see [`MenuPromptFacts`]).
last_menu_prompt: HashMap<BufferId, MenuPromptFacts>, last_menu_prompt: HashMap<BufferId, MenuPromptFacts>,
/// Last emitted `MinibufferPrompt` payload (Q#MB1) — a single value,
/// not per-buffer, because the minibuffer is one global core
/// instance.
last_minibuffer: Option<MinibufferFacts>,
/// `StyleSpans` recompute gate (perf). `scoped_style_spans` runs /// `StyleSpans` recompute gate (perf). `scoped_style_spans` runs
/// the tree-sitter highlights query over the *whole declared /// the tree-sitter highlights query over the *whole declared
/// viewport* (which the GPU frontend sets to the entire buffer) /// viewport* (which the GPU frontend sets to the entire buffer)
@ -207,6 +237,7 @@ impl SemanticRenderState {
last_adornments: HashMap::new(), last_adornments: HashMap::new(),
last_search_prompt: HashMap::new(), last_search_prompt: HashMap::new(),
last_menu_prompt: HashMap::new(), last_menu_prompt: HashMap::new(),
last_minibuffer: None,
last_summary: HashMap::new(), last_summary: HashMap::new(),
last_status: HashMap::new(), last_status: HashMap::new(),
last_style_gate: HashMap::new(), last_style_gate: HashMap::new(),
@ -411,6 +442,8 @@ impl SemanticRenderState {
out.extend(self.search_prompt_msg(state, vp.buffer_id)); out.extend(self.search_prompt_msg(state, vp.buffer_id));
// --- MenuPrompt (context menu; Q#CM1, protocol v11) --- // --- MenuPrompt (context menu; Q#CM1, protocol v11) ---
out.extend(self.menu_prompt_msg(state, vp.buffer_id)); 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));
out out
} }
@ -534,6 +567,65 @@ impl SemanticRenderState {
Some(msg) Some(msg)
} }
/// The `MinibufferPrompt` message for this frame, or `None` when the
/// (global) minibuffer state is unchanged (Q#MB1). Emitted only from
/// the active buffer's viewport so the bufferless message ships once
/// per frame. Closed = `prompt: None`; first sight while closed stays
/// silent. The daemon keeps the variant off wires negotiated `< 12`.
fn minibuffer_prompt_msg(
&mut self,
state: &EditorState,
buffer_id: BufferId,
) -> Option<InstanceMessage> {
let facts: MinibufferFacts = {
let core = state.core.borrow();
if buffer_id != core.active_buffer_id() {
return None;
}
let mb = &core.minibuffer;
match mb.session.as_ref() {
Some(session) => {
let input = mb.contents();
let cursor_byte = mb.cursor as usize;
let cursor = input
.char_indices()
.take_while(|(i, _)| *i < cursor_byte)
.count() as u32;
let total = session.candidates.len() as u32;
let (candidates, selected) =
minibuffer_window(&session.candidates, session.selected);
(
Some(session.prompt.clone()),
input,
cursor,
candidates,
selected,
total,
)
}
None => (None, String::new(), 0, Vec::new(), None, 0),
}
};
if self.last_minibuffer.as_ref() == Some(&facts) {
return None;
}
// First sight while closed: nothing to clear, stay silent.
if self.last_minibuffer.is_none() && facts.0.is_none() {
self.last_minibuffer = Some(facts);
return None;
}
let msg = InstanceMessage::MinibufferPrompt {
prompt: facts.0.clone(),
input: facts.1.clone(),
cursor: facts.2,
candidates: facts.3.clone(),
selected: facts.4,
total: facts.5,
};
self.last_minibuffer = Some(facts);
Some(msg)
}
/// The `StatusFacts` message for this frame, or `None` when /// The `StatusFacts` message for this frame, or `None` when
/// nothing changed. Carries the facts a semantic frontend cannot /// nothing changed. Carries the facts a semantic frontend cannot
/// derive locally: buffer name, modified flag, whole-file /// derive locally: buffer name, modified flag, whole-file
@ -3199,6 +3291,82 @@ mod tests {
); );
} }
#[test]
fn minibuffer_window_scrolls_to_keep_selection_visible() {
let cands: Vec<String> = (0..30).map(|i| format!("c{i}")).collect();
// No selection → top window, no highlight.
let (w, sel) = minibuffer_window(&cands, None);
assert_eq!(w.len(), MB_VISIBLE);
assert_eq!(w[0], "c0");
assert_eq!(sel, None);
// Deep selection scrolls; the selected row stays inside the window.
let (w, sel) = minibuffer_window(&cands, Some(20));
assert_eq!(w.len(), MB_VISIBLE);
assert_eq!(w[sel.unwrap() as usize], "c20");
// End selection clamps the window to the tail.
let (w, sel) = minibuffer_window(&cands, Some(29));
assert_eq!(w.last().unwrap(), "c29");
assert_eq!(w[sel.unwrap() as usize], "c29");
// Short list passes through with the selection intact.
let short: Vec<String> = vec!["a".into(), "b".into(), "c".into()];
assert_eq!(minibuffer_window(&short, Some(2)), (short.clone(), Some(2)));
// Empty.
assert_eq!(minibuffer_window(&[], Some(0)), (Vec::new(), None));
}
fn minibuffer_prompt_of(
msgs: &[InstanceMessage],
) -> Option<(Option<String>, String, Vec<String>)> {
msgs.iter().find_map(|m| match m {
InstanceMessage::MinibufferPrompt {
prompt,
input,
candidates,
..
} => Some((prompt.clone(), input.clone(), candidates.clone())),
_ => None,
})
}
#[test]
fn minibuffer_prompt_emits_prompt_input_and_windowed_candidates() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
// No minibuffer: the producer stays silent on first sight.
assert!(minibuffer_prompt_of(&s.render_frame(&state)).is_none());
// Open an `M-x` prompt (command completion) via the Lua API.
state
.lua_host
.lua()
.load("pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands', on_accept = function() end }")
.exec()
.expect("open minibuffer");
let (prompt, input, cands) =
minibuffer_prompt_of(&s.render_frame(&state)).expect("minibuffer prompt emitted");
assert_eq!(prompt.as_deref(), Some("M-x "));
assert_eq!(input, "");
// Empty input matches every command; the wire carries a window.
assert!(!cands.is_empty(), "M-x seeds command candidates");
assert!(cands.len() <= MB_VISIBLE, "candidates ship windowed");
// Unchanged → suppressed (cached-compare).
assert!(minibuffer_prompt_of(&s.render_frame(&state)).is_none());
// Cancel: the prompt clears (None).
state
.lua_host
.lua()
.load("pmacs.minibuffer.cancel()")
.exec()
.expect("cancel");
let (prompt, _, _) = minibuffer_prompt_of(&s.render_frame(&state)).expect("clear emitted");
assert!(prompt.is_none(), "cancel clears the minibuffer band");
}
#[test] #[test]
fn status_facts_emit_on_change_and_freeze_counts_while_stale() { fn status_facts_emit_on_change_and_freeze_counts_while_stale() {
let state = empty_state(); let state = empty_state();