tenseleyflow/fackr / e76eeee

Browse files

ghost text suggestions

Authored by espadonne
SHA
e76eeeec5bab2704ef17a867bc63509293ac6ea9
Parents
d40af3b
Tree
a92ada4

6 changed files

StatusFile+-
M src/buffer/rope.rs 25 0
M src/editor/state.rs 241 12
M src/render/screen.rs 20 0
A test_ghost.py 51 0
A test_ghost_text.rs 61 0
A test_simple.txt 61 0
src/buffer/rope.rsmodified
@@ -1,6 +1,7 @@
11
 use anyhow::Result;
22
 use ropey::Rope;
33
 use std::collections::hash_map::DefaultHasher;
4
+use std::collections::HashSet;
45
 use std::fs::File;
56
 use std::hash::{Hash, Hasher};
67
 use std::io::{BufReader, BufWriter};
@@ -174,6 +175,30 @@ impl Buffer {
174175
         self.text.to_string()
175176
     }
176177
 
178
+    /// Extract all unique words from the buffer for autocomplete.
179
+    /// Words are alphanumeric sequences with underscores, minimum 3 characters.
180
+    pub fn extract_words(&self) -> Vec<String> {
181
+        let mut words = HashSet::new();
182
+        let mut current_word = String::new();
183
+
184
+        for ch in self.text.chars() {
185
+            if ch.is_alphanumeric() || ch == '_' {
186
+                current_word.push(ch);
187
+            } else {
188
+                if current_word.len() >= 3 {
189
+                    words.insert(current_word.clone());
190
+                }
191
+                current_word.clear();
192
+            }
193
+        }
194
+        // Don't forget the last word
195
+        if current_word.len() >= 3 {
196
+            words.insert(current_word);
197
+        }
198
+
199
+        words.into_iter().collect()
200
+    }
201
+
177202
     /// Compute a hash of the buffer contents for change detection.
178203
     /// Uses cached value if available, otherwise computes and caches.
179204
     pub fn content_hash(&mut self) -> u64 {
src/editor/state.rsmodified
@@ -483,6 +483,33 @@ struct BracketMatchCache {
483483
     valid: bool,
484484
 }
485485
 
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
+
486513
 /// Main editor state
487514
 pub struct Editor {
488515
     /// The workspace (owns tabs, panes, fuss mode, and config)
@@ -511,6 +538,8 @@ pub struct Editor {
511538
     search_state: SearchState,
512539
     /// Cached bracket match for rendering
513540
     bracket_cache: BracketMatchCache,
541
+    /// Ghost text inline autocomplete state
542
+    ghost_text: GhostTextState,
514543
     /// Yank stack (kill ring) - separate from system clipboard
515544
     yank_stack: Vec<String>,
516545
     /// Current index in yank stack when cycling with Alt+Y
@@ -575,6 +604,7 @@ impl Editor {
575604
             server_manager: ServerManagerPanel::new(),
576605
             search_state: SearchState::default(),
577606
             bracket_cache: BracketMatchCache::default(),
607
+            ghost_text: GhostTextState::default(),
578608
             yank_stack: Vec::with_capacity(32),
579609
             yank_index: None,
580610
             last_yank_len: 0,
@@ -1448,6 +1478,152 @@ impl Editor {
14481478
         self.lsp_state.completion_index = 0;
14491479
     }
14501480
 
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
+
14511627
     /// Process a key event, handling ESC as potential Alt prefix
14521628
     fn process_key(&mut self, key_event: KeyEvent) -> Result<()> {
14531629
         use crossterm::event::{KeyCode, KeyModifiers};
@@ -1900,6 +2076,7 @@ impl Editor {
19002076
                     top_offset,
19012077
                     is_modified,
19022078
                     &mut buffer_entry.highlighter,
2079
+                    self.ghost_text.suggestion.as_deref(),
19032080
                 )?;
19042081
             }
19052082
 
@@ -2272,6 +2449,7 @@ impl Editor {
22722449
                 } else {
22732450
                     self.cursors_mut().primary_mut().clear_selection();
22742451
                 }
2452
+                self.dismiss_ghost_text();
22752453
             }
22762454
 
22772455
             // === Undo/Redo ===
@@ -2318,20 +2496,44 @@ impl Editor {
23182496
             (Key::Char('f'), Modifiers { alt: true, .. }) => self.move_word_right(false),
23192497
 
23202498
             // === Movement with selection ===
2321
-            (Key::Up, Modifiers { shift, .. }) => self.move_up(*shift),
2322
-            (Key::Down, Modifiers { shift, .. }) => self.move_down(*shift),
2323
-            (Key::Left, Modifiers { shift, .. }) => self.move_left(*shift),
2324
-            (Key::Right, Modifiers { shift, .. }) => self.move_right(*shift),
2499
+            (Key::Up, Modifiers { shift, .. }) => {
2500
+                self.move_up(*shift);
2501
+                self.validate_ghost_text_position();
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
+            }
23252515
 
23262516
             // Home/End
2327
-            (Key::Home, Modifiers { shift, .. }) => self.move_home(*shift),
2328
-            (Key::End, Modifiers { shift, .. }) => self.move_end(*shift),
2517
+            (Key::Home, Modifiers { 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
+            }
23292525
             (Key::Char('a'), Modifiers { ctrl: true, shift, .. }) => self.smart_home(*shift),
23302526
             (Key::Char('e'), Modifiers { ctrl: true, shift, .. }) => self.move_end(*shift),
23312527
 
23322528
             // Page movement
2333
-            (Key::PageUp, Modifiers { shift, .. }) => self.page_up(*shift),
2334
-            (Key::PageDown, Modifiers { shift, .. }) => self.page_down(*shift),
2529
+            (Key::PageUp, Modifiers { 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
+            }
23352537
 
23362538
             // Join lines: Ctrl+J
23372539
             (Key::Char('j'), Modifiers { ctrl: true, .. }) => self.join_lines(),
@@ -2374,13 +2576,30 @@ impl Editor {
23742576
             (Key::Char(c), Modifiers { ctrl: false, alt: false, .. }) => {
23752577
                 self.insert_char(*c);
23762578
             }
2377
-            (Key::Enter, _) => self.insert_newline(),
2378
-            (Key::Backspace, Modifiers { alt: true, .. }) => self.delete_word_backward(),
2579
+            (Key::Enter, _) => {
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
+            }
23792587
             (Key::Backspace, _) | (Key::Char('h'), Modifiers { ctrl: true, .. }) => {
23802588
                 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
+                }
23812602
             }
2382
-            (Key::Delete, _) => self.delete_forward(),
2383
-            (Key::Tab, _) => self.insert_tab(),
23842603
             (Key::BackTab, _) => self.dedent(),
23852604
 
23862605
             // Delete word backward: Ctrl+W
@@ -3282,6 +3501,7 @@ impl Editor {
32823501
         // For multi-cursor, use simple insert (skip auto-pair complexity for now)
32833502
         if self.cursors().len() > 1 {
32843503
             self.insert_text_multi(&c.to_string());
3504
+            self.dismiss_ghost_text();
32853505
             return;
32863506
         }
32873507
 
@@ -3292,6 +3512,7 @@ impl Editor {
32923512
             if c == next_char && (c == ')' || c == ']' || c == '}' || c == '"' || c == '\'' || c == '`') {
32933513
                 self.cursor_mut().col += 1;
32943514
                 self.cursor_mut().desired_col = self.cursor().col;
3515
+                self.dismiss_ghost_text();
32953516
                 return;
32963517
             }
32973518
         }
@@ -3334,11 +3555,19 @@ impl Editor {
33343555
 
33353556
                 let cursor_after = self.cursor_pos();
33363557
                 self.history_mut().record_insert(idx, pair_str, cursor_before, cursor_after);
3558
+                self.dismiss_ghost_text();
33373559
                 return;
33383560
             }
33393561
         }
33403562
 
33413563
         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
+        }
33423571
     }
33433572
 
33443573
     /// Get character at cursor position (if any)
src/render/screen.rsmodified
@@ -1276,6 +1276,7 @@ impl Screen {
12761276
         top_offset: u16,
12771277
         is_modified: bool,
12781278
         highlighter: &mut Highlighter,
1279
+        ghost_text: Option<&str>,
12791280
     ) -> Result<()> {
12801281
         execute!(self.stdout, Hide)?;
12811282
 
@@ -1389,6 +1390,25 @@ impl Screen {
13891390
                         &secondary_cursors,
13901391
                         &adjusted_tokens,
13911392
                     )?;
1393
+
1394
+                    // Render ghost text on the current line after the cursor
1395
+                    if is_current_line {
1396
+                        if let Some(ghost) = ghost_text {
1397
+                            // Calculate remaining space for ghost text
1398
+                            let line_len = display_line.chars().count();
1399
+                            let remaining_cols = text_cols.saturating_sub(line_len);
1400
+                            if remaining_cols > 0 {
1401
+                                // Truncate ghost text if it doesn't fit
1402
+                                let ghost_display: String = ghost.chars().take(remaining_cols).collect();
1403
+                                execute!(
1404
+                                    self.stdout,
1405
+                                    SetBackgroundColor(line_bg),
1406
+                                    SetForegroundColor(Color::AnsiValue(240)), // Dim gray
1407
+                                    Print(&ghost_display),
1408
+                                )?;
1409
+                            }
1410
+                        }
1411
+                    }
13921412
                 }
13931413
 
13941414
                 execute!(
test_ghost.pyadded
@@ -0,0 +1,51 @@
1
+# Python test file for ghost text autocomplete
2
+
3
+def initialize_database():
4
+    print("initializing database")
5
+
6
+def initialize_server():
7
+    print("initializing server")
8
+
9
+def validate_input(data):
10
+    return data is not None
11
+
12
+def validate_output(result):
13
+    return result is not None
14
+
15
+def transform_data(input_data):
16
+    return input_data.upper()
17
+
18
+def transform_result(output_result):
19
+    return output_result.lower()
20
+
21
+class UserAuthentication:
22
+    def __init__(self):
23
+        self.authenticated = False
24
+
25
+    def authenticate_user(self, username, password):
26
+        self.authenticated = True
27
+        return self.authenticated
28
+
29
+class DataProcessor:
30
+    def __init__(self):
31
+        self.processing = False
32
+
33
+    def process_batch(self, items):
34
+        self.processing = True
35
+        return [item * 2 for item in items]
36
+
37
+# Test typing here:
38
+# Try: "init" -> should suggest "ialize_database" or "ialize_server"
39
+# Try: "vali" -> should suggest "date_input" or "date_output"
40
+# Try: "trans" -> should suggest "form_data" or "form_result"
41
+# Try: "User" -> should suggest "Authentication"
42
+# Try: "Data" -> should suggest "Processor"
43
+# Try: "auth" -> should suggest "enticated" or "enticate_user"
44
+# Try: "proc" -> should suggest "essing" or "ess_batch"
45
+
46
+def main():
47
+    # Type new code here to test autocomplete:
48
+    pass
49
+
50
+if __name__ == "__main__":
51
+    main()
test_ghost_text.rsadded
@@ -0,0 +1,61 @@
1
+// Test file for ghost text autocomplete
2
+// Try typing the first 2-3 characters of these words and see ghost suggestions
3
+
4
+fn authentication_handler() {
5
+    println!("authentication started");
6
+}
7
+
8
+fn authorization_check() {
9
+    println!("authorization verified");
10
+}
11
+
12
+fn calculate_total(items: Vec<i32>) -> i32 {
13
+    items.iter().sum()
14
+}
15
+
16
+fn calculate_average(items: Vec<i32>) -> f64 {
17
+    let total = calculate_total(items.clone());
18
+    total as f64 / items.len() as f64
19
+}
20
+
21
+fn process_request(request: String) {
22
+    println!("processing: {}", request);
23
+}
24
+
25
+fn process_response(response: String) {
26
+    println!("response: {}", response);
27
+}
28
+
29
+struct Configuration {
30
+    database_url: String,
31
+    database_port: u16,
32
+    server_host: String,
33
+    server_port: u16,
34
+}
35
+
36
+impl Configuration {
37
+    fn new() -> Self {
38
+        Configuration {
39
+            database_url: String::from("localhost"),
40
+            database_port: 5432,
41
+            server_host: String::from("0.0.0.0"),
42
+            server_port: 8080,
43
+        }
44
+    }
45
+}
46
+
47
+// Test suggestions:
48
+// Type "auth" -> should suggest "entication" or "orization"
49
+// Type "calc" -> should suggest "ulate_total" or "ulate_average"
50
+// Type "proc" -> should suggest "ess_request" or "ess_response"
51
+// Type "data" -> should suggest "base_url" or "base_port"
52
+// Type "serv" -> should suggest "er_host" or "er_port"
53
+// Type "Conf" -> should suggest "iguration"
54
+
55
+fn main() {
56
+    let config = Configuration::new();
57
+
58
+    // Try typing here:
59
+    //
60
+
61
+}
test_simple.txtadded
@@ -0,0 +1,61 @@
1
+Ghost Text Autocomplete Test File
2
+==================================
3
+
4
+Words to test (type first 2-3 letters, then Tab to accept):
5
+
6
+hello
7
+hello
8
+hello
9
+
10
+world
11
+world
12
+world
13
+
14
+function
15
+function
16
+function
17
+
18
+variable
19
+variable
20
+variable
21
+
22
+testing
23
+testing
24
+testing
25
+
26
+autocomplete
27
+autocomplete
28
+autocomplete
29
+
30
+suggestion
31
+suggestion
32
+suggestion
33
+
34
+keyboard
35
+keyboard
36
+keyboard
37
+
38
+terminal
39
+terminal
40
+terminal
41
+
42
+implementation
43
+implementation
44
+implementation
45
+
46
+---
47
+
48
+Try typing below this line:
49
+
50
+
51
+
52
+---
53
+
54
+Expected behaviors:
55
+1. Type "hel" -> ghost text shows "lo" in dim gray
56
+2. Press Tab -> "lo" is inserted, cursor at end of "hello"
57
+3. Type "fun" -> ghost text shows "ction"
58
+4. Press Escape -> ghost text disappears
59
+5. Type "var" -> ghost text shows "iable"
60
+6. Press arrow key -> ghost text disappears
61
+7. Tab with no ghost text -> inserts 4 spaces (normal indent)