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:
parent
a6070e201d
commit
b9bd231e64
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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`].
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 v1–v5 ladder —
|
// every cell-carrying message, ending the v1–v5 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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue