@@ -483,6 +483,33 @@ struct BracketMatchCache { |
| 483 | valid: bool, | 483 | valid: bool, |
| 484 | } | 484 | } |
| 485 | | 485 | |
| | 486 | +/// Source of ghost text suggestion |
| | 487 | +#[derive(Debug, Default, Clone, Copy, PartialEq)] |
| | 488 | +enum GhostTextSource { |
| | 489 | + #[default] |
| | 490 | + None, |
| | 491 | + Lsp, |
| | 492 | + CurrentBuffer, |
| | 493 | + OtherBuffer, |
| | 494 | +} |
| | 495 | + |
| | 496 | +/// Ghost text inline autocomplete state |
| | 497 | +#[derive(Debug, Default)] |
| | 498 | +struct GhostTextState { |
| | 499 | + /// The ghost text suggestion (suffix to show/insert after cursor) |
| | 500 | + suggestion: Option<String>, |
| | 501 | + /// Position where suggestion applies (line, col) |
| | 502 | + position: Option<(usize, usize)>, |
| | 503 | + /// The prefix that was used to generate this suggestion |
| | 504 | + prefix: String, |
| | 505 | + /// Source of the current suggestion |
| | 506 | + source: GhostTextSource, |
| | 507 | + /// Cached word list from current buffer (content_hash, words) |
| | 508 | + buffer_words_cache: Option<(u64, Vec<String>)>, |
| | 509 | + /// Cached word list from all open buffers |
| | 510 | + all_buffer_words_cache: Vec<String>, |
| | 511 | +} |
| | 512 | + |
| 486 | /// Main editor state | 513 | /// Main editor state |
| 487 | pub struct Editor { | 514 | pub struct Editor { |
| 488 | /// The workspace (owns tabs, panes, fuss mode, and config) | 515 | /// The workspace (owns tabs, panes, fuss mode, and config) |
@@ -511,6 +538,8 @@ pub struct Editor { |
| 511 | search_state: SearchState, | 538 | search_state: SearchState, |
| 512 | /// Cached bracket match for rendering | 539 | /// Cached bracket match for rendering |
| 513 | bracket_cache: BracketMatchCache, | 540 | bracket_cache: BracketMatchCache, |
| | 541 | + /// Ghost text inline autocomplete state |
| | 542 | + ghost_text: GhostTextState, |
| 514 | /// Yank stack (kill ring) - separate from system clipboard | 543 | /// Yank stack (kill ring) - separate from system clipboard |
| 515 | yank_stack: Vec<String>, | 544 | yank_stack: Vec<String>, |
| 516 | /// Current index in yank stack when cycling with Alt+Y | 545 | /// Current index in yank stack when cycling with Alt+Y |
@@ -575,6 +604,7 @@ impl Editor { |
| 575 | server_manager: ServerManagerPanel::new(), | 604 | server_manager: ServerManagerPanel::new(), |
| 576 | search_state: SearchState::default(), | 605 | search_state: SearchState::default(), |
| 577 | bracket_cache: BracketMatchCache::default(), | 606 | bracket_cache: BracketMatchCache::default(), |
| | 607 | + ghost_text: GhostTextState::default(), |
| 578 | yank_stack: Vec::with_capacity(32), | 608 | yank_stack: Vec::with_capacity(32), |
| 579 | yank_index: None, | 609 | yank_index: None, |
| 580 | last_yank_len: 0, | 610 | last_yank_len: 0, |
@@ -1448,6 +1478,152 @@ impl Editor { |
| 1448 | self.lsp_state.completion_index = 0; | 1478 | self.lsp_state.completion_index = 0; |
| 1449 | } | 1479 | } |
| 1450 | | 1480 | |
| | 1481 | + // === Ghost Text (Inline Autocomplete) === |
| | 1482 | + |
| | 1483 | + /// Update ghost text suggestion based on current cursor position |
| | 1484 | + fn update_ghost_text(&mut self) { |
| | 1485 | + // Don't show ghost text if completion popup is visible |
| | 1486 | + if self.lsp_state.completion_visible { |
| | 1487 | + self.ghost_text.suggestion = None; |
| | 1488 | + return; |
| | 1489 | + } |
| | 1490 | + |
| | 1491 | + let cursor = self.cursor(); |
| | 1492 | + let line_idx = cursor.line; |
| | 1493 | + let col = cursor.col; |
| | 1494 | + |
| | 1495 | + // Get current line and extract prefix (word before cursor) |
| | 1496 | + let prefix = match self.buffer().line_str(line_idx) { |
| | 1497 | + Some(line) => { |
| | 1498 | + let before_cursor: String = line.chars().take(col).collect(); |
| | 1499 | + // Walk back to find word start |
| | 1500 | + before_cursor |
| | 1501 | + .chars() |
| | 1502 | + .rev() |
| | 1503 | + .take_while(|c| c.is_alphanumeric() || *c == '_') |
| | 1504 | + .collect::<String>() |
| | 1505 | + .chars() |
| | 1506 | + .rev() |
| | 1507 | + .collect::<String>() |
| | 1508 | + } |
| | 1509 | + None => return, |
| | 1510 | + }; |
| | 1511 | + |
| | 1512 | + // Don't suggest for very short prefixes |
| | 1513 | + if prefix.len() < 2 { |
| | 1514 | + self.ghost_text.suggestion = None; |
| | 1515 | + return; |
| | 1516 | + } |
| | 1517 | + |
| | 1518 | + // Try to find best suggestion from all sources |
| | 1519 | + if let Some(full_word) = self.find_best_suggestion(&prefix) { |
| | 1520 | + // Ghost text is the suffix (part after prefix) |
| | 1521 | + if full_word.len() > prefix.len() { |
| | 1522 | + let suffix = full_word[prefix.len()..].to_string(); |
| | 1523 | + self.ghost_text.suggestion = Some(suffix); |
| | 1524 | + self.ghost_text.position = Some((line_idx, col)); |
| | 1525 | + self.ghost_text.prefix = prefix; |
| | 1526 | + return; |
| | 1527 | + } |
| | 1528 | + } |
| | 1529 | + |
| | 1530 | + self.ghost_text.suggestion = None; |
| | 1531 | + } |
| | 1532 | + |
| | 1533 | + /// Find best matching suggestion from all sources (LSP, current buffer, other buffers) |
| | 1534 | + fn find_best_suggestion(&mut self, prefix: &str) -> Option<String> { |
| | 1535 | + let prefix_lower = prefix.to_lowercase(); |
| | 1536 | + |
| | 1537 | + // Priority 1: LSP completions (if we have recent ones) |
| | 1538 | + for item in &self.lsp_state.completions_original { |
| | 1539 | + let text = item.insert_text.as_ref().unwrap_or(&item.label); |
| | 1540 | + if text.to_lowercase().starts_with(&prefix_lower) && text.len() > prefix.len() { |
| | 1541 | + self.ghost_text.source = GhostTextSource::Lsp; |
| | 1542 | + return Some(text.clone()); |
| | 1543 | + } |
| | 1544 | + } |
| | 1545 | + |
| | 1546 | + // Priority 2: Current buffer words |
| | 1547 | + let buffer_hash = self.buffer_mut().content_hash(); |
| | 1548 | + let needs_refresh = self |
| | 1549 | + .ghost_text |
| | 1550 | + .buffer_words_cache |
| | 1551 | + .as_ref() |
| | 1552 | + .map(|(h, _)| *h != buffer_hash) |
| | 1553 | + .unwrap_or(true); |
| | 1554 | + |
| | 1555 | + if needs_refresh { |
| | 1556 | + let words = self.buffer().extract_words(); |
| | 1557 | + self.ghost_text.buffer_words_cache = Some((buffer_hash, words)); |
| | 1558 | + } |
| | 1559 | + |
| | 1560 | + if let Some((_, ref words)) = self.ghost_text.buffer_words_cache { |
| | 1561 | + for word in words { |
| | 1562 | + if word.to_lowercase().starts_with(&prefix_lower) |
| | 1563 | + && word != prefix |
| | 1564 | + && word.len() > prefix.len() |
| | 1565 | + { |
| | 1566 | + self.ghost_text.source = GhostTextSource::CurrentBuffer; |
| | 1567 | + return Some(word.clone()); |
| | 1568 | + } |
| | 1569 | + } |
| | 1570 | + } |
| | 1571 | + |
| | 1572 | + // Priority 3: Words from other buffers |
| | 1573 | + for word in &self.ghost_text.all_buffer_words_cache { |
| | 1574 | + if word.to_lowercase().starts_with(&prefix_lower) |
| | 1575 | + && word != prefix |
| | 1576 | + && word.len() > prefix.len() |
| | 1577 | + { |
| | 1578 | + self.ghost_text.source = GhostTextSource::OtherBuffer; |
| | 1579 | + return Some(word.clone()); |
| | 1580 | + } |
| | 1581 | + } |
| | 1582 | + |
| | 1583 | + None |
| | 1584 | + } |
| | 1585 | + |
| | 1586 | + /// Collect words from all open buffers in workspace |
| | 1587 | + fn collect_all_buffer_words(&self) -> Vec<String> { |
| | 1588 | + let mut words = std::collections::HashSet::new(); |
| | 1589 | + for tab in &self.workspace.tabs { |
| | 1590 | + for buffer_entry in &tab.buffers { |
| | 1591 | + for word in buffer_entry.buffer.extract_words() { |
| | 1592 | + words.insert(word); |
| | 1593 | + } |
| | 1594 | + } |
| | 1595 | + } |
| | 1596 | + words.into_iter().collect() |
| | 1597 | + } |
| | 1598 | + |
| | 1599 | + /// Accept the current ghost text suggestion |
| | 1600 | + fn accept_ghost_text(&mut self) { |
| | 1601 | + if let Some(suffix) = self.ghost_text.suggestion.take() { |
| | 1602 | + self.insert_text(&suffix); |
| | 1603 | + self.ghost_text.position = None; |
| | 1604 | + self.ghost_text.prefix.clear(); |
| | 1605 | + } |
| | 1606 | + } |
| | 1607 | + |
| | 1608 | + /// Dismiss ghost text (clear suggestion) |
| | 1609 | + fn dismiss_ghost_text(&mut self) { |
| | 1610 | + self.ghost_text.suggestion = None; |
| | 1611 | + self.ghost_text.position = None; |
| | 1612 | + self.ghost_text.prefix.clear(); |
| | 1613 | + } |
| | 1614 | + |
| | 1615 | + /// Check if ghost text should be dismissed due to cursor movement |
| | 1616 | + fn validate_ghost_text_position(&mut self) { |
| | 1617 | + if let Some((line, col)) = self.ghost_text.position { |
| | 1618 | + let cursor = self.cursor(); |
| | 1619 | + // Dismiss if cursor moved to different line or before the prefix start |
| | 1620 | + let prefix_start = col.saturating_sub(self.ghost_text.prefix.len()); |
| | 1621 | + if cursor.line != line || cursor.col < prefix_start || cursor.col > col { |
| | 1622 | + self.dismiss_ghost_text(); |
| | 1623 | + } |
| | 1624 | + } |
| | 1625 | + } |
| | 1626 | + |
| 1451 | /// Process a key event, handling ESC as potential Alt prefix | 1627 | /// Process a key event, handling ESC as potential Alt prefix |
| 1452 | fn process_key(&mut self, key_event: KeyEvent) -> Result<()> { | 1628 | fn process_key(&mut self, key_event: KeyEvent) -> Result<()> { |
| 1453 | use crossterm::event::{KeyCode, KeyModifiers}; | 1629 | use crossterm::event::{KeyCode, KeyModifiers}; |
@@ -1900,6 +2076,7 @@ impl Editor { |
| 1900 | top_offset, | 2076 | top_offset, |
| 1901 | is_modified, | 2077 | is_modified, |
| 1902 | &mut buffer_entry.highlighter, | 2078 | &mut buffer_entry.highlighter, |
| | 2079 | + self.ghost_text.suggestion.as_deref(), |
| 1903 | )?; | 2080 | )?; |
| 1904 | } | 2081 | } |
| 1905 | | 2082 | |
@@ -2272,6 +2449,7 @@ impl Editor { |
| 2272 | } else { | 2449 | } else { |
| 2273 | self.cursors_mut().primary_mut().clear_selection(); | 2450 | self.cursors_mut().primary_mut().clear_selection(); |
| 2274 | } | 2451 | } |
| | 2452 | + self.dismiss_ghost_text(); |
| 2275 | } | 2453 | } |
| 2276 | | 2454 | |
| 2277 | // === Undo/Redo === | 2455 | // === Undo/Redo === |
@@ -2318,20 +2496,44 @@ impl Editor { |
| 2318 | (Key::Char('f'), Modifiers { alt: true, .. }) => self.move_word_right(false), | 2496 | (Key::Char('f'), Modifiers { alt: true, .. }) => self.move_word_right(false), |
| 2319 | | 2497 | |
| 2320 | // === Movement with selection === | 2498 | // === Movement with selection === |
| 2321 | - (Key::Up, Modifiers { shift, .. }) => self.move_up(*shift), | 2499 | + (Key::Up, Modifiers { shift, .. }) => { |
| 2322 | - (Key::Down, Modifiers { shift, .. }) => self.move_down(*shift), | 2500 | + self.move_up(*shift); |
| 2323 | - (Key::Left, Modifiers { shift, .. }) => self.move_left(*shift), | 2501 | + self.validate_ghost_text_position(); |
| 2324 | - (Key::Right, Modifiers { shift, .. }) => self.move_right(*shift), | 2502 | + } |
| | 2503 | + (Key::Down, Modifiers { shift, .. }) => { |
| | 2504 | + self.move_down(*shift); |
| | 2505 | + self.validate_ghost_text_position(); |
| | 2506 | + } |
| | 2507 | + (Key::Left, Modifiers { shift, .. }) => { |
| | 2508 | + self.move_left(*shift); |
| | 2509 | + self.validate_ghost_text_position(); |
| | 2510 | + } |
| | 2511 | + (Key::Right, Modifiers { shift, .. }) => { |
| | 2512 | + self.move_right(*shift); |
| | 2513 | + self.validate_ghost_text_position(); |
| | 2514 | + } |
| 2325 | | 2515 | |
| 2326 | // Home/End | 2516 | // Home/End |
| 2327 | - (Key::Home, Modifiers { shift, .. }) => self.move_home(*shift), | 2517 | + (Key::Home, Modifiers { shift, .. }) => { |
| 2328 | - (Key::End, Modifiers { shift, .. }) => self.move_end(*shift), | 2518 | + self.move_home(*shift); |
| | 2519 | + self.validate_ghost_text_position(); |
| | 2520 | + } |
| | 2521 | + (Key::End, Modifiers { shift, .. }) => { |
| | 2522 | + self.move_end(*shift); |
| | 2523 | + self.validate_ghost_text_position(); |
| | 2524 | + } |
| 2329 | (Key::Char('a'), Modifiers { ctrl: true, shift, .. }) => self.smart_home(*shift), | 2525 | (Key::Char('a'), Modifiers { ctrl: true, shift, .. }) => self.smart_home(*shift), |
| 2330 | (Key::Char('e'), Modifiers { ctrl: true, shift, .. }) => self.move_end(*shift), | 2526 | (Key::Char('e'), Modifiers { ctrl: true, shift, .. }) => self.move_end(*shift), |
| 2331 | | 2527 | |
| 2332 | // Page movement | 2528 | // Page movement |
| 2333 | - (Key::PageUp, Modifiers { shift, .. }) => self.page_up(*shift), | 2529 | + (Key::PageUp, Modifiers { shift, .. }) => { |
| 2334 | - (Key::PageDown, Modifiers { shift, .. }) => self.page_down(*shift), | 2530 | + self.page_up(*shift); |
| | 2531 | + self.validate_ghost_text_position(); |
| | 2532 | + } |
| | 2533 | + (Key::PageDown, Modifiers { shift, .. }) => { |
| | 2534 | + self.page_down(*shift); |
| | 2535 | + self.validate_ghost_text_position(); |
| | 2536 | + } |
| 2335 | | 2537 | |
| 2336 | // Join lines: Ctrl+J | 2538 | // Join lines: Ctrl+J |
| 2337 | (Key::Char('j'), Modifiers { ctrl: true, .. }) => self.join_lines(), | 2539 | (Key::Char('j'), Modifiers { ctrl: true, .. }) => self.join_lines(), |
@@ -2374,13 +2576,30 @@ impl Editor { |
| 2374 | (Key::Char(c), Modifiers { ctrl: false, alt: false, .. }) => { | 2576 | (Key::Char(c), Modifiers { ctrl: false, alt: false, .. }) => { |
| 2375 | self.insert_char(*c); | 2577 | self.insert_char(*c); |
| 2376 | } | 2578 | } |
| 2377 | - (Key::Enter, _) => self.insert_newline(), | 2579 | + (Key::Enter, _) => { |
| 2378 | - (Key::Backspace, Modifiers { alt: true, .. }) => self.delete_word_backward(), | 2580 | + self.insert_newline(); |
| | 2581 | + self.dismiss_ghost_text(); |
| | 2582 | + } |
| | 2583 | + (Key::Backspace, Modifiers { alt: true, .. }) => { |
| | 2584 | + self.delete_word_backward(); |
| | 2585 | + self.update_ghost_text(); |
| | 2586 | + } |
| 2379 | (Key::Backspace, _) | (Key::Char('h'), Modifiers { ctrl: true, .. }) => { | 2587 | (Key::Backspace, _) | (Key::Char('h'), Modifiers { ctrl: true, .. }) => { |
| 2380 | self.delete_backward(); | 2588 | self.delete_backward(); |
| | 2589 | + self.update_ghost_text(); |
| | 2590 | + } |
| | 2591 | + (Key::Delete, _) => { |
| | 2592 | + self.delete_forward(); |
| | 2593 | + self.dismiss_ghost_text(); |
| | 2594 | + } |
| | 2595 | + (Key::Tab, _) => { |
| | 2596 | + // Accept ghost text if visible and no selection |
| | 2597 | + if self.ghost_text.suggestion.is_some() && !self.cursor().has_selection() { |
| | 2598 | + self.accept_ghost_text(); |
| | 2599 | + } else { |
| | 2600 | + self.insert_tab(); |
| | 2601 | + } |
| 2381 | } | 2602 | } |
| 2382 | - (Key::Delete, _) => self.delete_forward(), | | |
| 2383 | - (Key::Tab, _) => self.insert_tab(), | | |
| 2384 | (Key::BackTab, _) => self.dedent(), | 2603 | (Key::BackTab, _) => self.dedent(), |
| 2385 | | 2604 | |
| 2386 | // Delete word backward: Ctrl+W | 2605 | // Delete word backward: Ctrl+W |
@@ -3282,6 +3501,7 @@ impl Editor { |
| 3282 | // For multi-cursor, use simple insert (skip auto-pair complexity for now) | 3501 | // For multi-cursor, use simple insert (skip auto-pair complexity for now) |
| 3283 | if self.cursors().len() > 1 { | 3502 | if self.cursors().len() > 1 { |
| 3284 | self.insert_text_multi(&c.to_string()); | 3503 | self.insert_text_multi(&c.to_string()); |
| | 3504 | + self.dismiss_ghost_text(); |
| 3285 | return; | 3505 | return; |
| 3286 | } | 3506 | } |
| 3287 | | 3507 | |
@@ -3292,6 +3512,7 @@ impl Editor { |
| 3292 | if c == next_char && (c == ')' || c == ']' || c == '}' || c == '"' || c == '\'' || c == '`') { | 3512 | if c == next_char && (c == ')' || c == ']' || c == '}' || c == '"' || c == '\'' || c == '`') { |
| 3293 | self.cursor_mut().col += 1; | 3513 | self.cursor_mut().col += 1; |
| 3294 | self.cursor_mut().desired_col = self.cursor().col; | 3514 | self.cursor_mut().desired_col = self.cursor().col; |
| | 3515 | + self.dismiss_ghost_text(); |
| 3295 | return; | 3516 | return; |
| 3296 | } | 3517 | } |
| 3297 | } | 3518 | } |
@@ -3334,11 +3555,19 @@ impl Editor { |
| 3334 | | 3555 | |
| 3335 | let cursor_after = self.cursor_pos(); | 3556 | let cursor_after = self.cursor_pos(); |
| 3336 | self.history_mut().record_insert(idx, pair_str, cursor_before, cursor_after); | 3557 | self.history_mut().record_insert(idx, pair_str, cursor_before, cursor_after); |
| | 3558 | + self.dismiss_ghost_text(); |
| 3337 | return; | 3559 | return; |
| 3338 | } | 3560 | } |
| 3339 | } | 3561 | } |
| 3340 | | 3562 | |
| 3341 | self.insert_text(&c.to_string()); | 3563 | self.insert_text(&c.to_string()); |
| | 3564 | + |
| | 3565 | + // Update ghost text after alphanumeric input |
| | 3566 | + if c.is_alphanumeric() || c == '_' { |
| | 3567 | + self.update_ghost_text(); |
| | 3568 | + } else { |
| | 3569 | + self.dismiss_ghost_text(); |
| | 3570 | + } |
| 3342 | } | 3571 | } |
| 3343 | | 3572 | |
| 3344 | /// Get character at cursor position (if any) | 3573 | /// Get character at cursor position (if any) |