feat(completion): popup session store, self-positioning view, dispatcher shadow

Q#C2: CompletionPopupState (buffer + byte anchor + prefix + candidates
+ selection) behind SharedCompletionPopup on EditorCore — the
completion twin of SharedMenu. Q#C4: CompletionView reworked from the
dormant M4.7 store-keyed full-viewport painter into a self-positioning
overlay (MenuView model): windows candidates around the selection,
maps the byte anchor to a screen cell via the diag-view walk, places
below the anchor row (flips above when nothing fits), self-suppresses
when closed or on a foreign buffer. Q#C3: dispatch_key gains a PARTIAL
shadow — only TAB/RET/C-n/C-p/Up/Down/Esc/C-g intercept while the
popup is open; everything else falls through so typing keeps
self-inserting, with post-dispatch validation (active buffer, cursor
at/after anchor, word bytes between) closing broken sessions after the
after-edit hook has had its chance to refresh. Q#C7: accept
re-validates at the moment of accept and applies a single Replace
(one undo step; empty-prefix trigger sessions degrade to Insert),
firing buffer.after-edit through the existing revision check.

Framing: docs/in-buffer-completion-framing.md. Lua driver + bindings
follow in this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-07 11:42:30 -04:00
parent 21d12b486b
commit 361cc542c7
3 changed files with 838 additions and 87 deletions

View File

@ -30,8 +30,9 @@ use std::sync::{Arc, Mutex};
use serde_json::Value;
use unicode_width::UnicodeWidthChar;
use crate::buffer::Buffer;
use crate::cell::{Cell, CellCoord, CellGrid, Color, Glyph, Style};
use crate::buffer::{Buffer, BufferId};
use crate::cell::{CellCoord, CellGrid, Color, Glyph, Style};
use crate::rope::Position;
use crate::view::{View, Viewport};
// ---------------------------------------------------------------------------
@ -453,6 +454,114 @@ impl CompletionTriggers {
}
}
// ---------------------------------------------------------------------------
// In-buffer completion popup session (Arc 1a, Q#C2)
// ---------------------------------------------------------------------------
/// One row of the in-buffer completion popup: a projection of a
/// [`crate::completion_framework::CompletionCandidate`] carrying only
/// what rendering and the accept path need. `insert_text` is already
/// resolved (label fallback applied) so accept never re-derives it.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PopupCandidate {
/// Display label.
pub label: String,
/// Item kind (drives the glyph column).
pub kind: CompletionItemKind,
/// Optional one-line detail rendered after the label.
pub detail: Option<String>,
/// Text that replaces `[anchor .. cursor]` on accept.
pub insert_text: String,
}
/// Live state of the in-buffer completion popup (Q#C2). Frontend-
/// agnostic, mirroring [`crate::menu::MenuState`]: the Lua driver
/// publishes into it, the TUI [`CompletionView`] overlay renders from
/// it, the dispatcher's completion shadow navigates/accepts against
/// it, and (phase 2) the semantic producer ships it to the GPU.
///
/// Unlike the menu's cell anchor, `anchor` is a **byte offset** (the
/// prefix start) --- each frontend maps byte → screen position itself,
/// so the instance never learns a pixel.
pub struct CompletionPopupState {
/// Buffer the popup targets. The session closes the moment the
/// active buffer differs (Q#C3 validation).
pub buffer_id: BufferId,
/// Byte offset where the typed prefix starts. For a
/// trigger-character session (e.g. right after `.`) the prefix is
/// empty and `anchor` equals the cursor.
pub anchor: Position,
/// The prefix as of the last publish (refresh keeps it current).
pub prefix: String,
/// Candidates, best-first. The driver has already scored, dropped
/// non-matches, and capped.
pub candidates: Vec<PopupCandidate>,
/// Highlighted row index into `candidates`.
pub selected: usize,
/// Full candidate count before any cap the driver applied.
pub total: usize,
}
impl CompletionPopupState {
/// Build a session. Returns `None` when `candidates` is empty ---
/// an empty popup never opens (the driver enforces this too; this
/// is the belt to its suspenders).
#[must_use]
pub fn new(
buffer_id: BufferId,
anchor: Position,
prefix: String,
candidates: Vec<PopupCandidate>,
total: usize,
) -> Option<Self> {
if candidates.is_empty() {
return None;
}
Some(Self {
buffer_id,
anchor,
prefix,
candidates,
selected: 0,
total,
})
}
/// Move the highlight by `delta`, wrapping at the ends.
#[allow(
clippy::cast_possible_wrap,
reason = "candidate indices are bounded by Vec::len() which fits in isize on every supported target"
)]
pub fn step(&mut self, delta: isize) {
let len = self.candidates.len() as isize;
if len == 0 {
return;
}
let mut next = (self.selected as isize + delta) % len;
if next < 0 {
next += len;
}
self.selected = next as usize;
}
/// The highlighted candidate.
#[must_use]
pub fn selected_candidate(&self) -> Option<&PopupCandidate> {
self.candidates.get(self.selected)
}
}
/// Shared handle to the open popup (`None` when closed). Held by
/// [`crate::editor_core::EditorCore`] and read by [`CompletionView`],
/// the completion twin of [`crate::menu::SharedMenu`].
pub type SharedCompletionPopup = Arc<Mutex<Option<CompletionPopupState>>>;
/// A fresh, closed shared popup.
#[must_use]
pub fn make_shared_popup() -> SharedCompletionPopup {
Arc::new(Mutex::new(None))
}
// ---------------------------------------------------------------------------
// View
// ---------------------------------------------------------------------------
@ -462,6 +571,18 @@ impl CompletionTriggers {
/// rust-analyzer reply.
const DEFAULT_POPUP_WIDTH: u32 = 40;
/// Rows the popup shows at once; when more candidates are live the
/// visible slice windows around the selection (mirroring the
/// minibuffer dropdown's `MB_VISIBLE` cap).
const POPUP_MAX_ROWS: u32 = 10;
/// Minimum popup width in cells (glyph column + a readable label).
const POPUP_MIN_WIDTH: u32 = 12;
/// Tab-stop width in display columns, matching [`crate::diag`] /
/// [`crate::text_view`].
const TAB_WIDTH: u32 = 8;
/// Style for the currently-selected row (reverse video so it pops on
/// any base palette).
fn selected_style() -> Style {
@ -475,111 +596,266 @@ fn selected_style() -> Style {
fn kind_style() -> Style {
Style {
fg: Color::Indexed(8),
bg: Color::Indexed(236),
..Style::default()
}
}
/// Popup view that draws the completion list at its viewport's
/// origin. The viewport's `cell_size` bounds the popup; the host
/// (Lua) decides where to put it.
/// Popup background (non-selected rows) --- the same dim fill as the
/// context menu, so the popup reads as a floating surface over the
/// buffer text it occludes.
fn popup_style() -> Style {
Style {
fg: Color::Indexed(252),
bg: Color::Indexed(236),
..Style::default()
}
}
/// The visible slice of `n` candidates windowed around `selected`:
/// returns `(start, len)`. Mirrors the minibuffer dropdown's centered
/// window so the highlight stays in view as the user cycles.
#[must_use]
pub(crate) fn popup_window(n: usize, selected: usize, max: usize) -> (usize, usize) {
if n <= max {
return (0, n);
}
let half = max / 2;
let start = selected.saturating_sub(half).min(n - max);
(start, max)
}
/// Self-positioning popup overlay for the in-buffer completion session
/// (Q#C4). Persistent on the active window once attached (deduped by
/// [`View::kind`]); renders nothing while the popup is closed or the
/// window shows a different buffer, mirroring [`crate::menu::MenuView`]'s
/// self-suppressing model. Owns every cell inside the popup rectangle.
///
/// Unlike [`crate::diag::DiagnosticView`], this view does **not**
/// compose over a buffer's text --- it owns every cell inside its
/// viewport. The `_buf` parameter is unused.
/// Placement: the row *below* the anchor's screen row, with as many
/// rows as fit; when nothing fits below, it flips *above* the anchor.
/// The left edge sits at the anchor's display column, shifted left when
/// the popup would overflow the window's right edge.
pub struct CompletionView {
key: CompletionKey,
store: SharedCompletionStore,
popup: SharedCompletionPopup,
}
impl CompletionView {
/// Construct a popup view for `key` against `store`.
/// Build a view reading `popup`.
#[must_use]
pub fn new(key: CompletionKey, store: SharedCompletionStore) -> Self {
Self { key, store }
pub fn new(popup: SharedCompletionPopup) -> Self {
Self { popup }
}
}
/// Display column of `byte_end` within `line_bytes` (tab-aware,
/// UTF-8-aware). The completion twin of the diagnostic underline's
/// column resolution.
fn display_col_for_byte(line_bytes: &[u8], byte_end: u32) -> u32 {
let end = (byte_end as usize).min(line_bytes.len());
let text = String::from_utf8_lossy(&line_bytes[..end]);
let mut col = 0u32;
for ch in text.chars() {
if ch == '\t' {
col += TAB_WIDTH - (col % TAB_WIDTH);
} else {
col += char_display_width(ch);
}
}
col
}
/// Resolved popup rectangle, in window-relative cells.
struct PopupRect {
/// First popup row, relative to the viewport's top.
top: u32,
/// Left edge, relative to the viewport's left.
left: u32,
/// Popup width in cells.
width: u32,
/// Rows actually shown (≤ the windowed candidate count).
shown: u32,
}
/// Map the popup's byte anchor to a clamped on-screen rectangle:
/// below the anchor row when at least one row fits, flipped above
/// otherwise; left edge at the anchor's display column, shifted back
/// from the right margin. `None` when the anchor is scrolled out of
/// the viewport or nothing fits.
fn resolve_popup_rect(
buf: &Buffer,
viewport: Viewport,
anchor: Position,
rows: &[PopupCandidate],
) -> Option<PopupRect> {
// Anchor byte → (screen row, display col), the diag-view walk.
let source: Vec<u8> = {
let mut bytes = vec![0u8; buf.len() as usize];
if !bytes.is_empty() {
buf.snapshot_rope().slice(0, buf.len(), &mut bytes);
}
bytes
};
let anchor = (anchor as usize).min(source.len()) as u32;
let line_offsets = crate::diag::compute_line_offsets(&source);
let start_line = crate::diag::line_at_offset(&line_offsets, viewport.buffer_start as u32);
let anchor_line = crate::diag::line_at_offset(&line_offsets, anchor);
if anchor_line < start_line {
return None; // anchor scrolled above the viewport
}
let anchor_row = anchor_line - start_line;
let max_rows = viewport.cell_size.rows;
let max_cols = viewport.cell_size.cols;
if anchor_row >= max_rows || max_cols == 0 {
return None; // anchor scrolled below the viewport
}
let line_start = line_offsets[anchor_line as usize];
let line_end = line_offsets
.get(anchor_line as usize + 1)
.copied()
.unwrap_or(source.len() as u32);
let line_bytes = &source[line_start as usize..line_end as usize];
let anchor_col = display_col_for_byte(line_bytes, anchor - line_start);
// Vertical placement: below the anchor row when at least one row
// fits, else flipped above.
let want_rows = rows.len() as u32;
let below = max_rows - anchor_row - 1;
let (top, shown) = if below > 0 {
(anchor_row + 1, want_rows.min(below))
} else {
let shown = want_rows.min(anchor_row);
(anchor_row - shown, shown)
};
if shown == 0 {
return None;
}
/// The key this view is keyed under.
#[must_use]
pub fn key(&self) -> &CompletionKey {
&self.key
// Width: glyph column + widest visible "label detail", clamped to
// the window; left edge shifts back from the right margin.
let widest = rows
.iter()
.map(|c| {
let detail = c.detail.as_deref().map_or(0, |d| d.chars().count() + 2);
(c.label.chars().count() + detail) as u32
})
.max()
.unwrap_or(0);
let width = (widest + 3)
.clamp(POPUP_MIN_WIDTH, DEFAULT_POPUP_WIDTH)
.min(max_cols);
let left = anchor_col.min(max_cols - width);
Some(PopupRect {
top,
left,
width,
shown,
})
}
/// Paint one popup row (background fill, kind glyph, label + detail)
/// at absolute row `r`, columns `[abs_left .. abs_left + width)`.
fn paint_popup_row(
cells: &mut CellGrid<'_>,
item: &PopupCandidate,
r: u32,
abs_left: u32,
width: u32,
selected: bool,
) {
let row_style = if selected {
selected_style()
} else {
popup_style()
};
// Paint the whole row's background first so the selected row's
// reverse video covers the trailing whitespace.
for c in 0..width {
let cell = cells.at(CellCoord::new(r, abs_left + c));
cell.glyph = Glyph::Char(' ');
cell.style = row_style;
cell.attachment = None;
}
// Column 0: kind glyph.
let kind_cell = cells.at(CellCoord::new(r, abs_left));
kind_cell.glyph = Glyph::Char(item.kind.glyph());
kind_cell.style = if selected {
selected_style()
} else {
kind_style()
};
// Columns 2..: label, optionally followed by the detail.
let mut text = String::with_capacity(item.label.len() + 4);
text.push_str(&item.label);
if let Some(detail) = item.detail.as_deref() {
text.push_str(" ");
text.push_str(detail);
}
let mut col: u32 = 2;
for ch in text.chars() {
if col >= width {
break;
}
let cw = char_display_width(ch);
if cw == 0 {
continue;
}
let cell = cells.at(CellCoord::new(r, abs_left + col));
cell.glyph = Glyph::Char(ch);
cell.style = row_style;
cell.attachment = None;
col += 1;
if cw == 2 && col < width {
let cont = cells.at(CellCoord::new(r, abs_left + col));
cont.glyph = Glyph::Continuation;
cont.style = row_style;
cont.attachment = None;
col += 1;
}
}
}
impl View for CompletionView {
fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
let (items, selected) = {
let guard = self.store.lock().expect("completion store poisoned");
let items = guard.items(&self.key).to_vec();
(items, guard.selected(&self.key))
};
fn kind(&self) -> &'static str {
"completion-popup"
}
let max_rows = viewport.cell_size.rows;
let max_cols = viewport.cell_size.cols.max(1);
let origin = viewport.cell_origin;
let popup_cols = max_cols.min(DEFAULT_POPUP_WIDTH);
// Clear the popup region.
for r in 0..max_rows {
for c in 0..max_cols {
*cells.at(CellCoord::new(origin.row + r, origin.col + c)) = Cell::default();
fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
// Snapshot under the lock, then drop it before touching the rope.
let (anchor, rows_data, selected_in_window): (Position, Vec<PopupCandidate>, usize) = {
let guard = self.popup.lock().expect("completion popup poisoned");
let Some(popup) = guard.as_ref() else {
return;
};
if popup.buffer_id != buf.id() {
return; // this window shows a different buffer
}
}
if items.is_empty() {
let (start, len) = popup_window(
popup.candidates.len(),
popup.selected,
POPUP_MAX_ROWS as usize,
);
(
popup.anchor,
popup.candidates[start..start + len].to_vec(),
popup.selected - start,
)
};
if rows_data.is_empty() {
return;
}
for row in 0..max_rows.min(items.len() as u32) {
let item = &items[row as usize];
let row_style = if row as usize == selected {
selected_style()
} else {
Style::default()
};
// Paint the whole row's background first so the selected
// row's reverse video covers the trailing whitespace.
for c in 0..popup_cols {
let cell = cells.at(CellCoord::new(origin.row + row, origin.col + c));
cell.glyph = Glyph::Char(' ');
cell.style = row_style;
cell.attachment = None;
}
// Column 0: kind glyph.
let kind_cell = cells.at(CellCoord::new(origin.row + row, origin.col));
kind_cell.glyph = Glyph::Char(item.kind.glyph());
kind_cell.style = if row as usize == selected {
selected_style()
} else {
kind_style()
};
// Columns 2..: label, optionally followed by " : detail".
let mut text = String::with_capacity(item.label.len() + 4);
text.push_str(&item.label);
if let Some(detail) = item.detail.as_deref() {
text.push_str(" ");
text.push_str(detail);
}
let mut col: u32 = 2;
for ch in text.chars() {
if col >= popup_cols {
break;
}
let width = char_display_width(ch);
if width == 0 {
continue;
}
let cell = cells.at(CellCoord::new(origin.row + row, origin.col + col));
cell.glyph = Glyph::Char(ch);
cell.style = row_style;
cell.attachment = None;
col += 1;
if width == 2 && col < popup_cols {
let cont = cells.at(CellCoord::new(origin.row + row, origin.col + col));
cont.glyph = Glyph::Continuation;
cont.style = row_style;
cont.attachment = None;
col += 1;
}
}
let Some(rect) = resolve_popup_rect(buf, viewport, anchor, &rows_data) else {
return;
};
let origin = viewport.cell_origin;
for (i, item) in rows_data.iter().take(rect.shown as usize).enumerate() {
paint_popup_row(
cells,
item,
origin.row + rect.top + i as u32,
origin.col + rect.left,
rect.width,
i == selected_in_window,
);
}
}
}
@ -746,4 +1022,62 @@ mod tests {
assert!(r.items.is_empty());
assert!(!r.is_incomplete);
}
// ---- popup session (Arc 1a, Q#C2) ---------------------------------------
fn cand(label: &str) -> PopupCandidate {
PopupCandidate {
label: label.to_owned(),
kind: CompletionItemKind::Text,
detail: None,
insert_text: label.to_owned(),
}
}
#[test]
fn popup_state_refuses_empty_candidates() {
assert!(
CompletionPopupState::new(BufferId::from_raw(1), 0, String::new(), vec![], 0).is_none()
);
}
#[test]
fn popup_state_step_wraps_both_directions() {
let mut p = CompletionPopupState::new(
BufferId::from_raw(1),
0,
"ab".into(),
vec![cand("a"), cand("b"), cand("c")],
3,
)
.unwrap();
assert_eq!(p.selected, 0);
p.step(1);
assert_eq!(p.selected, 1);
p.step(4); // 1 + 4 = 5, mod 3 = 2
assert_eq!(p.selected, 2);
p.step(1); // wraps to the top
assert_eq!(p.selected, 0);
p.step(-1); // wraps to the bottom
assert_eq!(p.selected, 2);
assert_eq!(p.selected_candidate().unwrap().label, "c");
}
#[test]
fn popup_window_keeps_selection_visible() {
// Fits: identity window.
assert_eq!(popup_window(3, 0, 10), (0, 3));
// Overflow: centers on the selection…
assert_eq!(popup_window(30, 15, 10), (10, 10));
// …clamps at the top…
assert_eq!(popup_window(30, 0, 10), (0, 10));
assert_eq!(popup_window(30, 2, 10), (0, 10));
// …and at the bottom.
assert_eq!(popup_window(30, 29, 10), (20, 10));
// The selected index always falls inside the window.
for sel in 0..30 {
let (start, len) = popup_window(30, sel, 10);
assert!(sel >= start && sel < start + len, "sel {sel} escaped");
}
}
}

View File

@ -493,6 +493,19 @@ impl EditorState {
core.active_frontend = frontend_id;
}
// Modal surfaces beat the completion popup (Q#C3): if a menu /
// search / minibuffer opened while the popup was up, close the
// popup before the modal shadow swallows this key --- otherwise
// it would linger, rendered but unreachable.
{
let mut core = self.core.borrow_mut();
if core.completion_popup_is_open()
&& (core.menu_is_open() || core.search_active() || core.minibuffer.is_active())
{
core.completion_popup_close();
}
}
// Context-menu interception (Q#CM1): while a menu is open every
// key drives it (navigate / invoke / dismiss), shadowing the
// global keymap like search and the minibuffer. Same shared path
@ -522,6 +535,21 @@ impl EditorState {
return;
}
// In-buffer completion popup (Q#C3): a PARTIAL shadow, the
// fourth member of the family above. Only the popup-control
// chords (TAB / RET / C-n / C-p / Up / Down / Esc / C-g) are
// intercepted; every other key falls through to normal dispatch
// below, so typing keeps self-inserting and motion keys keep
// moving. The post-dispatch validation at the bottom of this
// function closes the session when a fallen-through key breaks
// the anchor/prefix invariant.
if self.core.borrow().completion_popup_is_open()
&& let Some(key) = CompletionPopupKey::from_chord(chord)
{
self.dispatch_completion_key(key);
return;
}
// Buffer-scope keybindings need the active buffer id passed
// through the dispatcher (otherwise `keymap_stack::resolve`
// skips the buffer-local map entirely and every "scope =
@ -575,6 +603,13 @@ impl EditorState {
self.lua_host
.run_hook("buffer.after-edit", mlua::MultiValue::new());
}
// Q#C3 post-dispatch validation, deliberately AFTER the
// after-edit hook: the Lua driver may have just refreshed (or
// re-anchored) the popup for this very edit, and validation
// must judge the fresh session, not the stale one. A closed
// popup makes this a single mutex peek.
self.core.borrow_mut().completion_popup_validate();
}
/// Active buffer's edit revision, or `None` if the registry no
@ -691,6 +726,28 @@ impl EditorState {
}
}
/// Drive the open completion popup from an intercepted control
/// chord (Q#C3). Accept (Q#C7) re-validates inside
/// [`EditorCore::completion_popup_accept`] and applies a single
/// Replace edit; when that edit lands, `buffer.after-edit` fires
/// here exactly as it does on the normal dispatch path, so LSP
/// `didChange` and styling refresh ride the existing machinery.
fn dispatch_completion_key(&mut self, key: CompletionPopupKey) {
match key {
CompletionPopupKey::Next => self.core.borrow_mut().completion_popup_step(1),
CompletionPopupKey::Prev => self.core.borrow_mut().completion_popup_step(-1),
CompletionPopupKey::Dismiss => self.core.borrow_mut().completion_popup_close(),
CompletionPopupKey::Accept => {
let pre_revision = self.active_buffer_revision();
self.core.borrow_mut().completion_popup_accept();
if pre_revision != self.active_buffer_revision() {
self.lua_host
.run_hook("buffer.after-edit", mlua::MultiValue::new());
}
}
}
}
/// Drive an open context menu from a keystroke (Q#CM1).
fn dispatch_menu_key(&mut self, chord: Chord) {
match MenuKey::from_chord(chord) {
@ -1558,6 +1615,56 @@ impl MenuKey {
}
}
/// Keys intercepted while the in-buffer completion popup is open
/// (Q#C3). Unlike [`SearchKey`] / [`MenuKey`] this is a **partial**
/// shadow: `from_chord` returns `None` for every chord outside the
/// popup-control set, and the dispatcher lets those fall through to
/// normal dispatch --- printable keys keep self-inserting, motion keys
/// keep moving (the post-dispatch validation then decides whether the
/// session survives). The same decode runs in both frontends via the
/// daemon's `FrontendEvent::Key` round-trip.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
enum CompletionPopupKey {
/// Highlight the next candidate (Down / C-n).
Next,
/// Highlight the previous candidate (Up / C-p).
Prev,
/// Accept the highlighted candidate (TAB / RET).
Accept,
/// Close the popup without accepting (Esc / C-g).
Dismiss,
}
impl CompletionPopupKey {
/// Decode `chord` into a popup action, or `None` when the chord is
/// not popup control and must fall through to normal dispatch.
fn from_chord(chord: Chord) -> Option<Self> {
let ctrl = chord.modifiers.contains(KeyModifiers::CONTROL);
let alt = chord.modifiers.contains(KeyModifiers::ALT);
if !ctrl && !alt {
return match chord.code {
KeyCode::Down => Some(Self::Next),
KeyCode::Up => Some(Self::Prev),
KeyCode::Tab | KeyCode::Enter => Some(Self::Accept),
KeyCode::Esc => Some(Self::Dismiss),
_ => None,
};
}
if ctrl
&& !alt
&& let KeyCode::Char(c) = chord.code
{
return match c {
'n' => Some(Self::Next),
'p' => Some(Self::Prev),
'g' => Some(Self::Dismiss),
_ => None,
};
}
None
}
}
/// Paint one full frame into `grid` and return the desired terminal
/// cursor position.
///

View File

@ -187,6 +187,12 @@ pub struct EditorCore {
/// from the same state the dispatch path mutates — the menu twin of
/// `search_store`.
pub menu: crate::menu::SharedMenu,
/// Open in-buffer completion popup (Arc 1a, Q#C2), or `None` when
/// closed. Shared `Arc<Mutex>` so the TUI
/// [`crate::completion::CompletionView`] overlay renders from the
/// same state the dispatch path navigates and the Lua driver
/// publishes into — the completion twin of `menu`.
pub completion_popup: crate::completion::SharedCompletionPopup,
}
impl EditorCore {
@ -226,6 +232,7 @@ impl EditorCore {
clipboard_slot: Vec::new(),
pending_clipboard: None,
menu: crate::menu::make_shared_menu(),
completion_popup: crate::completion::make_shared_popup(),
}
}
@ -1818,6 +1825,166 @@ impl EditorCore {
}
}
// ---- in-buffer completion popup (Arc 1a, Q#C2/Q#C3) --------------------
/// True while the in-buffer completion popup is open.
#[must_use]
pub fn completion_popup_is_open(&self) -> bool {
self.completion_popup
.lock()
.expect("completion popup poisoned")
.is_some()
}
/// Open (or replace) the completion popup session. Attaches the
/// self-suppressing [`crate::completion::CompletionView`] overlay to
/// the active window on first use (deduped by kind, like the menu).
/// Emptiness is enforced upstream:
/// [`crate::completion::CompletionPopupState::new`] refuses to build
/// a candidate-less session.
pub fn completion_popup_open(&mut self, state: crate::completion::CompletionPopupState) {
*self
.completion_popup
.lock()
.expect("completion popup poisoned") = Some(state);
self.ensure_completion_overlay();
}
/// Close the popup (the overlay then self-suppresses).
pub fn completion_popup_close(&mut self) {
*self
.completion_popup
.lock()
.expect("completion popup poisoned") = None;
}
/// Move the popup highlight by `delta` (wrapping).
pub fn completion_popup_step(&mut self, delta: isize) {
if let Some(p) = self
.completion_popup
.lock()
.expect("completion popup poisoned")
.as_mut()
{
p.step(delta);
}
}
/// Q#C3 session invariant: the popup only survives while the
/// active buffer still matches, the cursor sits at or after the
/// anchor, and every byte between them is a word byte (`[A-Za-z0-9_]`
/// --- the same ASCII word definition the Lua driver uses). A
/// trigger-character session (empty prefix, `cursor == anchor`)
/// holds trivially. Returns the `(anchor, cursor)` pair while the
/// invariant holds.
#[must_use]
fn completion_session_holds(&self) -> Option<(Position, Position)> {
/// Longest byte run still plausibly a completion prefix; past
/// this the session is stale, not a prefix.
const MAX_PREFIX_BYTES: u64 = 512;
let (buffer_id, anchor) = {
let guard = self
.completion_popup
.lock()
.expect("completion popup poisoned");
let p = guard.as_ref()?;
(p.buffer_id, p.anchor)
};
if self.active_buffer_id() != buffer_id {
return None;
}
let cursor = self.active_window().cursor;
if cursor < anchor || cursor - anchor > MAX_PREFIX_BYTES {
return None;
}
let reg = self.registry.borrow();
let buffer = reg.get(buffer_id).ok()?;
if cursor > buffer.len() {
return None;
}
let mut bytes = vec![0u8; (cursor - anchor) as usize];
if !bytes.is_empty() {
buffer.snapshot_rope().slice(anchor, cursor, &mut bytes);
}
bytes
.iter()
.all(|b| b.is_ascii_alphanumeric() || *b == b'_')
.then_some((anchor, cursor))
}
/// Q#C3 post-dispatch validation: close the popup unless the
/// session invariant still holds. Called by the dispatcher after
/// every fallen-through key (motion, edits, buffer switches) and
/// cheap enough to call unconditionally --- a closed popup is a
/// single mutex peek.
pub fn completion_popup_validate(&mut self) {
if self.completion_popup_is_open() && self.completion_session_holds().is_none() {
self.completion_popup_close();
}
}
/// Q#C7 accept: re-validate the session at the moment of accept,
/// close the popup, and --- only when the invariant still holds ---
/// replace `[anchor .. cursor]` with the highlighted candidate's
/// insert text as a **single** edit (one undo step, mirroring
/// [`Self::insert_char_over_region`]). Returns `true` iff the
/// buffer was edited (the dispatcher fires `buffer.after-edit`
/// off that signal).
pub fn completion_popup_accept(&mut self) -> bool {
let holds = self.completion_session_holds();
let snap = {
let guard = self
.completion_popup
.lock()
.expect("completion popup poisoned");
guard
.as_ref()
.and_then(|p| p.selected_candidate().map(|c| c.insert_text.clone()))
};
self.completion_popup_close();
let (Some((anchor, cursor)), Some(text)) = (holds, snap) else {
return false;
};
self.active_window_mut().goal_col = None;
// An empty range degenerates to a plain insert (the
// trigger-character case, where nothing was typed yet).
let result = if cursor > anchor {
self.apply_active_edit(EditOp::Replace {
range: Range {
start: anchor,
end: cursor,
},
bytes: text.as_bytes(),
})
} else {
self.apply_active_edit(EditOp::Insert {
pos: anchor,
bytes: text.as_bytes(),
})
};
if let Err(e) = result {
self.status = format!("completion accept failed: {e}");
return false;
}
let aw = self.active_window_mut();
aw.cursor = anchor + text.len() as u64;
aw.selection = None;
true
}
/// Ensure the active window carries a
/// [`crate::completion::CompletionView`] overlay (deduped by kind).
/// The view reads the shared popup, so one instance suffices; it
/// renders nothing while the popup is closed.
fn ensure_completion_overlay(&mut self) {
let popup = self.completion_popup.clone();
let win = self.active_window_mut();
if !win.overlay_kinds().contains(&"completion-popup") {
win.push_overlay(Box::new(crate::completion::CompletionView::new(popup)));
}
}
/// Safely remove `buffer_id` from the registry. Any window that
/// was displaying it is redirected to a fallback buffer (`*scratch*`,
/// created on demand) so window state never refers to a missing id.
@ -3072,4 +3239,147 @@ mod tests {
assert!(!s.search_is_regex());
assert_eq!(s.search_match_summary().1, 1);
}
// ---- in-buffer completion popup (Arc 1a) --------------------------------
fn text_of(s: &EditorCore) -> String {
let id = s.active_buffer_id();
let reg = s.registry.borrow();
let buf = reg.get(id).expect("active buffer present");
let mut bytes = vec![0u8; buf.len() as usize];
if !bytes.is_empty() {
buf.snapshot_rope().slice(0, buf.len(), &mut bytes);
}
String::from_utf8(bytes).expect("test buffers are UTF-8")
}
fn open_popup(s: &mut EditorCore, anchor: u64, prefix: &str, insert_text: &str) {
let state = crate::completion::CompletionPopupState::new(
s.active_buffer_id(),
anchor,
prefix.to_owned(),
vec![crate::completion::PopupCandidate {
label: insert_text.to_owned(),
kind: crate::completion::CompletionItemKind::Text,
detail: None,
insert_text: insert_text.to_owned(),
}],
1,
)
.expect("non-empty candidate list");
s.completion_popup_open(state);
}
#[test]
fn completion_popup_open_attaches_self_suppressing_overlay() {
let mut s = from_bytes(b"he\n");
s.active_window_mut().cursor = 2;
open_popup(&mut s, 0, "he", "hello");
assert!(s.completion_popup_is_open());
assert!(
s.active_window()
.overlay_kinds()
.contains(&"completion-popup")
);
// Re-opening dedups the overlay by kind.
open_popup(&mut s, 0, "he", "hello");
let kinds = s.active_window().overlay_kinds();
assert_eq!(
kinds.iter().filter(|k| **k == "completion-popup").count(),
1
);
}
#[test]
fn completion_popup_validate_survives_word_growth_and_empty_prefix() {
let mut s = from_bytes(b"he world\n");
s.active_window_mut().cursor = 2;
open_popup(&mut s, 0, "he", "hello");
s.completion_popup_validate();
assert!(s.completion_popup_is_open(), "prefix `he` holds");
// Typing extends the word: still valid.
s.insert_char('l');
s.completion_popup_validate();
assert!(s.completion_popup_is_open(), "prefix `hel` holds");
// Trigger-char shape (cursor == anchor, empty prefix) holds too.
s.completion_popup_close();
s.active_window_mut().cursor = 2;
open_popup(&mut s, 2, "", "llo");
s.completion_popup_validate();
assert!(s.completion_popup_is_open(), "empty prefix at anchor holds");
}
#[test]
fn completion_popup_validate_closes_when_invariant_breaks() {
let mut s = from_bytes(b"he world\n");
s.active_window_mut().cursor = 2;
open_popup(&mut s, 0, "he", "hello");
// Cursor moved past the word: `[anchor..cursor]` spans a space.
s.active_window_mut().cursor = 4;
s.completion_popup_validate();
assert!(!s.completion_popup_is_open(), "non-word bytes close it");
// Cursor moved before the anchor.
s.active_window_mut().cursor = 2;
open_popup(&mut s, 2, "", "x");
s.active_window_mut().cursor = 1;
s.completion_popup_validate();
assert!(!s.completion_popup_is_open(), "cursor < anchor closes it");
// Session bound to a buffer that is not the active one.
let other = s.registry.borrow_mut().create("*other*");
let state = crate::completion::CompletionPopupState::new(
other,
0,
String::new(),
vec![crate::completion::PopupCandidate {
label: "x".into(),
kind: crate::completion::CompletionItemKind::Text,
detail: None,
insert_text: "x".into(),
}],
1,
)
.unwrap();
s.completion_popup_open(state);
s.completion_popup_validate();
assert!(!s.completion_popup_is_open(), "wrong buffer closes it");
}
#[test]
fn completion_popup_accept_replaces_prefix_as_one_undo_step() {
let mut s = from_bytes(b"he and more\n");
s.active_window_mut().cursor = 2;
open_popup(&mut s, 0, "he", "hello_world");
assert!(s.completion_popup_accept());
assert_eq!(text_of(&s), "hello_world and more\n");
assert_eq!(s.active_window().cursor, 11);
assert!(!s.completion_popup_is_open(), "accept closes the popup");
// Q#C7: the replace is a single edit — one undo restores the
// original text (not an intermediate delete-then-insert state).
s.undo();
assert_eq!(text_of(&s), "he and more\n");
}
#[test]
fn completion_popup_accept_empty_prefix_inserts_at_anchor() {
let mut s = from_bytes(b"x.\n");
s.active_window_mut().cursor = 2;
open_popup(&mut s, 2, "", "method");
assert!(s.completion_popup_accept());
assert_eq!(text_of(&s), "x.method\n");
assert_eq!(s.active_window().cursor, 8);
}
#[test]
fn completion_popup_accept_is_noop_when_session_stale() {
let mut s = from_bytes(b"he world\n");
s.active_window_mut().cursor = 2;
open_popup(&mut s, 0, "he", "hello");
// Simulate a race: the cursor left the word before accept ran.
s.active_window_mut().cursor = 5;
assert!(!s.completion_popup_accept());
assert_eq!(text_of(&s), "he world\n", "buffer untouched");
assert!(!s.completion_popup_is_open(), "stale accept still closes");
}
}