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:
parent
21d12b486b
commit
361cc542c7
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
src/editor.rs
107
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<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.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue