From 361cc542c79cf9be810c0859697995cf87da1317 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 7 Jul 2026 11:42:30 -0400 Subject: [PATCH] feat(completion): popup session store, self-positioning view, dispatcher shadow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/completion.rs | 508 +++++++++++++++++++++++++++++++++++++-------- src/editor.rs | 107 ++++++++++ src/editor_core.rs | 310 +++++++++++++++++++++++++++ 3 files changed, 838 insertions(+), 87 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index 50318f9..046d8cf 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -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, + /// 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, + /// 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, + total: usize, + ) -> Option { + 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>>; + +/// 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 { + // Anchor byte → (screen row, display col), the diag-view walk. + let source: Vec = { + 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, 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"); + } + } } diff --git a/src/editor.rs b/src/editor.rs index 95d4388..4967a2e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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 { + 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. /// diff --git a/src/editor_core.rs b/src/editor_core.rs index 021748d..9de7c18 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -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` 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"); + } }