1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
//! Insert-mode key handling: completion popup, character/Backspace
//! fan-out across extra cursors, and the `LastChange::Insert`
//! recording for `.`.
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::action::{InsertKey, LastChange};
use crate::app::App;
use crate::app::SignatureTrigger;
use crate::app::completion::{
AUTO_TRIGGER_MIN_PREFIX_LEN, identifier_prefix_start, is_ident_continue,
};
use crate::editor::{Buffer, Cursor};
use crate::mode::Mode;
impl App {
pub(super) fn handle_insert_key(&mut self, key: KeyEvent) -> Result<()> {
// Completion popup, when open, intercepts navigation/accept
// keys before they reach the normal insert handling. Char
// input and Backspace fall through and trigger a re-filter
// afterwards. Esc closes the popup *first* (so the next Esc
// exits insert mode) — matches how every other editor behaves.
if self.completion.is_some()
&& let Some(()) = self.handle_completion_key(key)
{
return Ok(());
}
// `<C-Space>` triggers a completion request. We do this before
// the bare-char fast path so it doesn't get typed literally.
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl && key.code == KeyCode::Char(' ') {
self.lsp_completion();
return Ok(());
}
// `<C-l>` / `<C-y>` accept the inline (ghost-text) suggestion
// when one is showing. Both bind to the same action — `<C-l>`
// is the primary (chosen to coexist with the LSP completion
// popup, which Ghost text now paints under), `<C-y>` is kept
// for muscle-memory parity with the prior single-binding world.
// With nothing to accept they fall through to the no-op `_ =>
// {}` arm below — no literal `l`/`y` is inserted because the
// bare-char fast path gates on `no_ctrl`.
if ctrl
&& matches!(key.code, KeyCode::Char('l') | KeyCode::Char('y'))
&& self.accept_inline_suggestion()
{
return Ok(());
}
let no_ctrl = !key.modifiers.contains(KeyModifiers::CONTROL);
if no_ctrl && let KeyCode::Char(c) = key.code {
self.fan_out_insert_char(c);
self.record_insert_key(InsertKey::Char(c));
self.update_completion_filter();
// Auto-trigger completion when the user starts typing an
// identifier and no popup is open. Identifier-continue
// chars always qualify; for punctuation we defer to the
// server-declared `triggerCharacters` from initialize
// (e.g. `:` `.` `'` for rust-analyzer, `<` for tsserver).
// Other punctuation, whitespace, and operators don't fire
// a request on their own. If the popup is already open,
// the re-filter above is enough; we don't refire because
// items are stable for the same prefix-start.
if self.completion.is_none() && self.lsp.has_lsp() {
if self.lsp.is_completion_trigger_char(c) {
// Forward the actual trigger character so the
// server can switch to its trigger-character
// codepath (rust-analyzer needs this to surface
// path completions after `::`).
self.lsp_completion_triggered(c);
} else if is_ident_continue(c)
&& self.typed_prefix_len() >= AUTO_TRIGGER_MIN_PREFIX_LEN
{
// Hold off on the request until the user has typed
// enough of an identifier to narrow things down —
// firing on the first keystroke just dumps the
// server's full identifier table into the popup.
self.lsp_completion();
}
}
self.update_signature_help_on_char(c);
self.schedule_inline_suggestion();
return Ok(());
}
match key.code {
KeyCode::Esc => {
self.finalize_insert_recording();
self.cancel_signature_help();
self.cancel_inline_suggestion();
self.enter_mode(Mode::Normal);
}
KeyCode::Enter => {
// Multi-cursor with Enter is tricky (one cursor splits a
// line, others on the same line need their row/col
// recomputed). Punt for v1 — only the primary types
// newlines, extras stay put.
let indent = self.indent_settings();
self.buffer.insert_newline(indent);
self.record_insert_key(InsertKey::Newline);
self.cancel_completion();
// Newline almost always ends the call argument we were
// helping with (function call literals don't span lines
// in any language the popup targets) — close rather
// than retrigger.
self.cancel_signature_help();
self.schedule_inline_suggestion();
}
KeyCode::Backspace => {
self.fan_out_backspace();
self.record_insert_key(InsertKey::Backspace);
self.update_completion_filter();
self.update_signature_help_on_edit();
self.schedule_inline_suggestion();
}
KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
// Some terminals (e.g. macOS Terminal.app) report
// Shift+Tab as `Tab` + SHIFT instead of `BackTab`. Treat
// both as dedent so users don't need to know which they
// have.
self.fan_out_dedent();
self.record_insert_key(InsertKey::Dedent);
self.update_completion_filter();
}
KeyCode::Tab => {
// Honor the buffer's indent settings: `use_tabs` mode
// inserts a literal `\t`, soft-tab mode inserts enough
// spaces to reach the next tab stop at column-multiple
// `width`. Soft-tab uses char column for the stop math;
// there shouldn't be `\t` characters in leading
// whitespace when `use_tabs` is false, so visual vs.
// char column converge in practice.
//
// Fallback: if the configured indent character is space
// but the current line's leading whitespace already
// contains tabs (the indent character "isn't found" in
// the line's style), fall through to a literal `\t` so
// we don't mix soft-tab spaces into a tab-indented row.
let indent = self.indent_settings();
let leading_has_tab = self.buffer.lines[self.buffer.cursor.row]
.chars()
.take_while(|c| c.is_whitespace())
.any(|c| c == '\t');
if indent.use_tabs || leading_has_tab {
self.fan_out_insert_char('\t');
self.record_insert_key(InsertKey::Char('\t'));
} else {
let stop = indent.width.max(1);
let col = self.buffer.cursor.col;
let n = stop - (col % stop);
for _ in 0..n {
self.fan_out_insert_char(' ');
self.record_insert_key(InsertKey::Char(' '));
}
}
self.update_completion_filter();
}
KeyCode::BackTab => {
self.fan_out_dedent();
self.record_insert_key(InsertKey::Dedent);
self.update_completion_filter();
}
// Arrow keys break vim's `.` recording — drop the in-flight
// session so the next `.` replays only the typing up to here.
// Extra cursors follow the same motion as the primary; coincident
// cursors are merged by `scatter_cursors`.
KeyCode::Left => {
self.recording = None;
self.fan_out_move(|b| b.move_left());
self.cancel_completion();
self.update_signature_help_on_edit();
self.schedule_inline_suggestion();
}
KeyCode::Right => {
self.recording = None;
self.fan_out_move(|b| b.move_right(true));
self.cancel_completion();
self.update_signature_help_on_edit();
self.schedule_inline_suggestion();
}
KeyCode::Up => {
self.recording = None;
self.fan_out_move(|b| b.move_up());
self.cancel_completion();
self.cancel_signature_help();
self.schedule_inline_suggestion();
}
KeyCode::Down => {
self.recording = None;
self.fan_out_move(|b| b.move_down());
self.cancel_completion();
self.cancel_signature_help();
self.schedule_inline_suggestion();
}
_ => {}
}
Ok(())
}
/// Handle a key event while the completion popup is open. Returns
/// `Some(())` when the key was absorbed by the popup (selection
/// changed, popup closed, item accepted) — caller should bail.
/// Returns `None` when the key should fall through to the normal
/// insert-mode handling (typing a character that re-filters,
/// backspace, etc.).
fn handle_completion_key(&mut self, key: KeyEvent) -> Option<()> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
// Direction of the nav request, if this key is a nav key.
// `None` means the key isn't navigation.
let nav_delta: Option<isize> = match key.code {
KeyCode::Esc => {
self.cancel_completion();
return Some(());
}
KeyCode::Up | KeyCode::BackTab => Some(-1),
KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => Some(-1),
KeyCode::Down | KeyCode::Tab => Some(1),
KeyCode::Char('p') if ctrl => Some(-1),
KeyCode::Char('n') if ctrl => Some(1),
KeyCode::Enter => {
// Accept only when the popup is in selecting mode. In
// preview mode (just opened by auto-trigger, no row
// committed-to yet) let Enter fall through to insert a
// literal newline.
let selecting = self
.completion
.as_ref()
.map(|s| s.selecting)
.unwrap_or(false);
if !selecting {
self.cancel_completion();
return None;
}
self.accept_completion();
return Some(());
}
_ => return None,
};
if let Some(delta) = nav_delta
&& let Some(s) = self.completion.as_mut()
{
// First nav keypress only flips into selecting mode; the
// initial row at `selected = 0` (or wherever refilter left
// it) is what gets highlighted. Subsequent presses actually
// step the selection.
if !s.selecting {
s.selecting = true;
// For backward nav, jump to the last row so BackTab /
// Up from preview mode lands at the bottom — matches
// what users expect after typing Tab to "open" and
// BackTab to "look at the bottom item".
if delta < 0 && !s.filtered.is_empty() {
s.selected = s.filtered.len() - 1;
}
} else {
s.move_selection(delta);
}
}
// Now in selecting mode: pull `detail` / `documentation` for
// the current row in case the server deferred them.
self.resolve_current_completion_for_detail();
Some(())
}
/// Apply `insert_char(c)` at the primary cursor and every extra
/// cursor, adjusting positions so each extra ends up "after its own
/// inserted character" in the final buffer.
///
/// Strategy: tag positions with their original index (primary = 0),
/// sort descending by `(row, col)`, and process in that order. The
/// descending order guarantees that a later, lower-position edit
/// can't shift any cursor we haven't processed yet. After each
/// edit, every *already-processed* cursor on the same row gets
/// `col += 1` — that one earlier cursor's character index has been
/// pushed right by the insertion we just did at a lower column.
fn fan_out_insert_char(&mut self, ch: char) {
if self.buffer.extra_cursors.is_empty() {
let indent = self.indent_settings();
self.buffer.insert_char_smart(ch, indent);
return;
}
let mut all = collect_cursors(self);
all.sort_by_key(|(_, c)| std::cmp::Reverse((c.row, c.col)));
let mut new_positions = vec![Cursor::default(); all.len()];
for i in 0..all.len() {
let (orig_idx, pos) = all[i];
self.buffer.cursor = pos;
self.buffer.insert_char(ch);
new_positions[orig_idx] = self.buffer.cursor;
for (other_orig_idx, _) in all.iter().take(i) {
if new_positions[*other_orig_idx].row == pos.row {
new_positions[*other_orig_idx].col += 1;
}
}
}
scatter_cursors(self, new_positions);
}
/// Backspace fan-out. Two cases:
/// - cursor at col > 0: same-row deletion, mirrors `insert_char`
/// fan-out with `col -= 1` shifts on same row.
/// - cursor at col == 0: this would join with the previous line.
/// We skip fan-out for these (only primary joins) — handling
/// the row collapse on every extra is a separate bookkeeping
/// problem we're leaving to v2.
fn fan_out_backspace(&mut self) {
if self.buffer.extra_cursors.is_empty() {
let indent = self.indent_settings();
self.buffer.delete_char_before_smart(indent);
return;
}
let mut all = collect_cursors(self);
all.sort_by_key(|(_, c)| std::cmp::Reverse((c.row, c.col)));
let mut new_positions = vec![Cursor::default(); all.len()];
for i in 0..all.len() {
let (orig_idx, pos) = all[i];
if pos.col == 0 {
// Only the primary performs the line-join; extras at
// col 0 stay put rather than risk a row collapse.
if orig_idx == 0 {
self.buffer.cursor = pos;
self.buffer.delete_char_before();
new_positions[orig_idx] = self.buffer.cursor;
} else {
new_positions[orig_idx] = pos;
}
continue;
}
self.buffer.cursor = pos;
self.buffer.delete_char_before();
new_positions[orig_idx] = self.buffer.cursor;
for (other_orig_idx, _) in all.iter().take(i) {
if new_positions[*other_orig_idx].row == pos.row
&& new_positions[*other_orig_idx].col > 0
{
new_positions[*other_orig_idx].col -= 1;
}
}
}
scatter_cursors(self, new_positions);
}
/// Shift+Tab fan-out: dedent each *unique* row that holds a cursor
/// once, then shift every cursor on that row left by the number of
/// chars stripped. Per-row rather than per-cursor because two
/// cursors on the same row would otherwise double-dedent (or worse,
/// the second call would see a different leading-whitespace state
/// than the first).
fn fan_out_dedent(&mut self) {
let indent = self.indent_settings();
if self.buffer.extra_cursors.is_empty() {
self.buffer.dedent_current_line(indent);
return;
}
let all = collect_cursors(self);
let mut rows: Vec<usize> = all.iter().map(|(_, c)| c.row).collect();
rows.sort_unstable();
rows.dedup();
let mut removed: Vec<(usize, usize)> = Vec::with_capacity(rows.len());
for row in rows {
let before = self.buffer.lines[row].chars().count();
self.buffer.dedent_line(row, indent);
let after = self.buffer.lines[row].chars().count();
removed.push((row, before - after));
}
let mut new_positions = vec![Cursor::default(); all.len()];
for (orig_idx, pos) in &all {
let n = removed
.iter()
.find(|(r, _)| *r == pos.row)
.map(|(_, n)| *n)
.unwrap_or(0);
new_positions[*orig_idx] = Cursor {
row: pos.row,
col: pos.col.saturating_sub(n),
};
}
scatter_cursors(self, new_positions);
}
/// Apply a cursor motion at the primary cursor and every extra
/// cursor. Each cursor moves independently; coincident results are
/// merged by `scatter_cursors`.
fn fan_out_move(&mut self, mut f: impl FnMut(&mut Buffer)) {
if self.buffer.extra_cursors.is_empty() {
f(&mut self.buffer);
return;
}
let all = collect_cursors(self);
let mut new_positions = vec![Cursor::default(); all.len()];
for (orig_idx, pos) in &all {
self.buffer.cursor = *pos;
f(&mut self.buffer);
new_positions[*orig_idx] = self.buffer.cursor;
}
scatter_cursors(self, new_positions);
}
/// Apply a bracketed-paste payload at the primary cursor. Bypasses
/// the auto-indent / auto-pair / completion machinery so the pasted
/// text's own indentation survives intact (otherwise every `\n`
/// fires the Enter handler and the per-line indent stacks on top
/// of what's already there). Extras stay put — paste fans out the
/// way newlines do, primary only, for the same bookkeeping reason.
pub(super) fn insert_pasted_text(&mut self, s: String) {
if s.is_empty() {
return;
}
self.cancel_completion();
self.cancel_signature_help();
self.schedule_inline_suggestion();
self.buffer.insert_text_raw(&s);
self.record_insert_key(InsertKey::Paste(s));
}
fn record_insert_key(&mut self, k: InsertKey) {
if let Some(r) = self.recording.as_mut() {
r.keys.push(k);
}
}
/// Decide whether typing `c` should trigger or refresh the
/// signature-help popup. Three cases:
/// - Popup is open: refresh with a `ContentChange` retrigger so the
/// server can advance `activeParameter` as the user types args.
/// We don't bother distinguishing retrigger characters here — any
/// keystroke can shift which parameter the cursor is in.
/// - Popup closed and `c` is a server-declared trigger char (`(` is
/// the common case): open from scratch.
/// - Otherwise: nothing.
fn update_signature_help_on_char(&mut self, c: char) {
if !self.lsp.has_lsp() {
return;
}
if self.signature.is_some() {
self.lsp_signature_help(SignatureTrigger::ContentChange(Some(c)));
} else if self.lsp.is_signature_help_trigger_char(c) {
self.lsp_signature_help(SignatureTrigger::TriggerCharacter(c));
}
}
/// Refresh the signature popup after a non-character edit (backspace
/// or horizontal cursor move). No-op when the popup isn't open —
/// backspacing in code where no help is showing shouldn't poke the
/// server.
fn update_signature_help_on_edit(&mut self) {
if self.signature.is_none() || !self.lsp.has_lsp() {
return;
}
self.lsp_signature_help(SignatureTrigger::ContentChange(None));
}
/// Length, in chars, of the identifier run that ends at the
/// primary cursor — i.e. how many ident-continue chars sit
/// immediately before the cursor on its row. Drives the
/// auto-trigger floor so we don't fire `textDocument/completion`
/// on a single-letter prefix.
fn typed_prefix_len(&self) -> usize {
let cursor = self.buffer.cursor;
let line = &self.buffer.lines[cursor.row];
let start = identifier_prefix_start(line, cursor.col);
cursor.col.saturating_sub(start)
}
/// Move the in-flight recording into `last_change`. Called on Esc
/// out of an Insert session that began with a recordable trigger.
fn finalize_insert_recording(&mut self) {
if let Some(r) = self.recording.take() {
self.last_change = Some(LastChange::Insert {
trigger: r.trigger,
keys: r.keys,
});
}
}
}
/// Snapshot every active cursor (primary first, then extras in their
/// stored order) tagged with its original index. The original index is
/// what `scatter_cursors` writes back to: index 0 is the primary,
/// 1..N go back into `extra_cursors[0..N-1]` so the pop ordering of
/// `<C-p>` is preserved.
fn collect_cursors(app: &App) -> Vec<(usize, Cursor)> {
std::iter::once((0usize, app.buffer.cursor))
.chain(
app.buffer
.extra_cursors
.iter()
.enumerate()
.map(|(i, c)| (i + 1, *c)),
)
.collect()
}
/// Inverse of `collect_cursors`. `positions[0]` is the new primary;
/// `positions[1..]` becomes the new extras (preserving their original
/// ordering). Dedupes any extra that landed on the primary or on an
/// earlier extra, since coincident cursors are visually indistinguishable
/// and would just amplify subsequent edits.
fn scatter_cursors(app: &mut App, positions: Vec<Cursor>) {
app.buffer.cursor = positions[0];
let primary = positions[0];
let mut extras: Vec<Cursor> = Vec::with_capacity(positions.len() - 1);
for c in positions.into_iter().skip(1) {
if c == primary || extras.contains(&c) {
continue;
}
extras.push(c);
}
app.buffer.extra_cursors = extras;
}