ratatui_code_editor/
editor.rs

1use crossterm::event::{
2    KeyEvent, KeyModifiers,
3    MouseEvent, MouseEventKind, MouseButton,
4};
5use ratatui::style::Color;
6use ratatui::style::Style;
7use ratatui::prelude::*;
8use std::time::Duration;
9use crate::click::{ClickKind, ClickTracker};
10use crate::code::Code;
11use crate::code::{EditKind, EditBatch};
12use crate::code::{RopeGraphemes, grapheme_width_and_chars_len, grapheme_width};
13use crate::selection::{Selection, SelectionSnap};
14use crate::actions::*;
15use crate::utils;
16use std::collections::HashMap;
17use std::cell::RefCell;
18use std::cmp::Ordering;
19use anyhow::{Result, anyhow};
20
21// keyword and ratatui style
22type Theme = HashMap<String, Style>;
23// start byte, end byte, style
24type Hightlight = (usize, usize, Style);
25// start offset, end offset
26type HightlightCache = HashMap<(usize, usize), Vec<Hightlight>>;
27
28/// Represents the text editor, which holds the code buffer, cursor, selection,
29/// theme, scroll offsets, highlight cache, clipboard, and user mark intervals.
30pub struct Editor {
31    /// Code buffer and editing/highlighting logic for the current language
32    pub(crate) code: Code,
33    /// Current cursor position as a character index in the document
34    pub(crate) cursor: usize,
35
36    /// Vertical scroll offset: index of the first visible line
37    pub(crate) offset_y: usize,
38
39    /// Horizontal scroll offset in characters (visual columns)
40    pub(crate) offset_x: usize,
41
42    /// Syntax theme: mapping of token name to ratatui Style
43    pub(crate) theme: Theme,
44
45    /// Current text selection, if any
46    pub(crate) selection: Option<Selection>,
47
48    /// Click tracker to detect single/double/triple clicks
49    pub(crate) clicks: ClickTracker,
50
51    /// Selection snapping mode (to word, to line, or none)
52    pub(crate) selection_snap: SelectionSnap,
53
54    /// Fallback clipboard storage when the system clipboard is unavailable
55    pub(crate) clipboard: Option<String>,
56
57    /// User marks for intervals: (start, end, color)
58    pub(crate) marks: Option<Vec<(usize, usize, Color)>>,
59
60    /// Syntax highlight cache by intervals to speed up rendering
61    pub(crate) highlights_cache: RefCell<HightlightCache>,
62}
63
64impl Editor {
65    /// Create a new editor instance with language, text, and theme
66    pub fn new(lang: &str, text: &str, theme: Vec<(&str, &str)>) -> Self {
67        let code = Code::new(text, lang)
68            .or_else(|_| Code::new(text, "text"))
69            .unwrap();
70
71        let theme = Self::build_theme(&theme);
72        let highlights_cache = RefCell::new(HashMap::new());
73
74        Self {
75            code,
76            cursor: 0,
77            offset_y: 0,
78            offset_x: 0,
79            theme,
80            selection: None,
81            clicks: ClickTracker::new(Duration::from_millis(700)),
82            selection_snap: SelectionSnap::None,
83            clipboard: None,
84            marks: None,
85            highlights_cache,
86        }
87    }
88
89    pub fn input(
90        &mut self, key: KeyEvent, area: &Rect,
91    ) -> Result<()> {
92        use crossterm::event::KeyCode;
93
94        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
95        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
96        let _alt = key.modifiers.contains(KeyModifiers::ALT);
97
98        match key.code {
99            KeyCode::Char('รท') => self.apply(ToggleComment { }),
100            KeyCode::Char('z') if ctrl => self.apply(Undo { }),
101            KeyCode::Char('y') if ctrl => self.apply(Redo { }),
102            KeyCode::Char('c') if ctrl => self.apply(Copy { }),
103            KeyCode::Char('v') if ctrl => self.apply(Paste { }),
104            KeyCode::Char('x') if ctrl => self.apply(Cut { }),
105            KeyCode::Char('k') if ctrl => self.apply(DeleteLine { }),
106            KeyCode::Char('d') if ctrl => self.apply(Duplicate { }),
107            KeyCode::Char('a') if ctrl => self.apply(SelectAll { }),
108            KeyCode::Left      => self.apply(MoveLeft { shift }),
109            KeyCode::Right     => self.apply(MoveRight { shift }),
110            KeyCode::Up        => self.apply(MoveUp { shift }),
111            KeyCode::Down      => self.apply(MoveDown { shift }),
112            KeyCode::Backspace => self.apply(Delete { }),
113            KeyCode::Enter     => self.apply(InsertNewline { }),
114            KeyCode::Char(c)   => self.apply(InsertText { text: c.to_string() }),
115            KeyCode::Tab       => self.apply(Indent { }),
116            KeyCode::BackTab   => self.apply(UnIndent { }),
117            _ => {}
118        }
119        self.focus(&area);
120        Ok(())
121    }
122    
123    pub fn focus(&mut self, area: &Rect) {
124        let width = area.width as usize;
125        let height = area.height as usize;
126        let total_lines = self.code.len_lines();
127        let max_line_number = total_lines.max(1);
128        let line_number_digits = max_line_number.to_string().len().max(5);
129        let line_number_width = (line_number_digits + 2) as usize;
130
131        let line = self.code.char_to_line(self.cursor);
132        let col = self.cursor - self.code.line_to_char(line);
133    
134        let visible_width = width.saturating_sub(line_number_width);
135        let visible_height = height;
136    
137        let step_size = 10;
138        if col < self.offset_x {
139            self.offset_x = col.saturating_sub(step_size);
140        } else if col >= self.offset_x + visible_width {
141            self.offset_x = col.saturating_sub(visible_width - step_size);
142        }
143    
144        if line < self.offset_y {
145            self.offset_y = line;
146        } else if line >= self.offset_y + visible_height {
147            self.offset_y = line.saturating_sub(visible_height - 1);
148        }
149    }
150
151    pub fn mouse(
152        &mut self, mouse: MouseEvent, area: &Rect,
153    ) -> Result<()> {
154
155        match mouse.kind {
156            MouseEventKind::ScrollUp => self.scroll_up(),
157            MouseEventKind::ScrollDown => self.scroll_down(area.height as usize),
158            MouseEventKind::Down(MouseButton::Left) => {
159                let pos = self.cursor_from_mouse(mouse.column, mouse.row, area);
160                if let Some(cursor) = pos {
161                    self.handle_mouse_down(cursor);
162                }
163            }
164            MouseEventKind::Drag(MouseButton::Left) => {
165                // Auto-scroll when dragging on the last or first visible row
166                if mouse.row == area.top() {
167                    self.scroll_up();
168                }
169                if mouse.row == area.bottom().saturating_sub(1) {
170                    self.scroll_down(area.height as usize);
171                }
172                let pos = self.cursor_from_mouse(mouse.column, mouse.row, area);
173                if let Some(cursor) = pos {
174                    self.handle_mouse_drag(cursor);
175                }
176            }
177            MouseEventKind::Up(MouseButton::Left) => {
178                self.selection_snap = SelectionSnap::None;
179            }
180            _ => {}
181        }
182        Ok(())
183    }
184
185    fn handle_mouse_down(&mut self, cursor: usize) {
186        let kind = self.clicks.register(cursor);
187        let (start, end, snap) = match kind {
188            ClickKind::Triple => {
189                let (line_start, line_end) = self.code.line_boundaries(cursor);
190                (line_start, line_end, SelectionSnap::Line { anchor: cursor })
191            }
192            ClickKind::Double => {
193                let (word_start, word_end) = self.code.word_boundaries(cursor);
194                (word_start, word_end, SelectionSnap::Word { anchor: cursor })
195            }
196            ClickKind::Single => (cursor, cursor, SelectionSnap::None),
197        };
198
199        self.selection = Some(Selection::from_anchor_and_cursor(start, end));
200        self.cursor = end;
201        self.selection_snap = snap;
202    }
203
204    fn handle_mouse_drag(&mut self, cursor: usize) {
205        match self.selection_snap {
206            SelectionSnap::Line { anchor } => {
207                let (anchor_start, anchor_end) = self.code.line_boundaries(anchor);
208                let (cur_start, cur_end) = self.code.line_boundaries(cursor);
209        
210                let (sel_start, sel_end, new_cursor) = match cursor.cmp(&anchor) {
211                    Ordering::Greater => (anchor_start, cur_end, cur_end),   // forward
212                    Ordering::Less => (cur_start, anchor_end, cur_start),    // backward
213                    Ordering::Equal => (anchor_start, anchor_end, anchor_end), 
214                };
215        
216                self.selection = Some(Selection::from_anchor_and_cursor(sel_start, sel_end));
217                self.cursor = new_cursor;
218            }
219            SelectionSnap::Word { anchor } => {
220                let (anchor_start, anchor_end) = self.code.word_boundaries(anchor);
221                let (cur_start, cur_end) = self.code.word_boundaries(cursor);
222        
223                let (sel_start, sel_end, new_cursor) = match cursor.cmp(&anchor) {
224                    Ordering::Greater => (anchor_start, cur_end, cur_end),   // forward
225                    Ordering::Less => (cur_start, anchor_end, cur_start),    // backward
226                    Ordering::Equal => (anchor_start, anchor_end, anchor_end),
227                };
228        
229                self.selection = Some(Selection::from_anchor_and_cursor(sel_start, sel_end));
230                self.cursor = new_cursor;
231            }
232            SelectionSnap::None => {
233                let anchor = self.selection_anchor();
234                self.selection = Some(Selection::from_anchor_and_cursor(anchor, cursor));
235                self.cursor = cursor;
236            }
237        }
238    }
239
240    fn cursor_from_mouse(
241        &self, mouse_x: u16, mouse_y: u16, area: &Rect
242    ) -> Option<usize> {
243        let total_lines = self.code.len_lines();
244        let max_line_number = total_lines.max(1);
245        let line_number_digits = max_line_number.to_string().len().max(5);
246        let line_number_width = (line_number_digits + 2) as u16;
247    
248        if mouse_y < area.top()
249            || mouse_y >= area.bottom()
250            || mouse_x < area.left() + line_number_width
251        {
252            return None;
253        }
254    
255        let clicked_row = (mouse_y - area.top()) as usize + self.offset_y;
256        if clicked_row >= self.code.len_lines() {
257            return None;
258        }
259    
260        let clicked_col = (mouse_x - area.left() - line_number_width) as usize;
261    
262        let line_start_char = self.code.line_to_char(clicked_row);
263        let line_len = self.code.line_len(clicked_row);
264    
265        let start_col = self.offset_x.min(line_len);
266        let end_col = line_len;
267    
268        let char_start = line_start_char + start_col;
269        let char_end = line_start_char + end_col;
270    
271        let mut current_col = 0;
272        let mut char_idx = start_col;        
273        let visible_chars = self.code.char_slice(char_start, char_end);
274        for g in RopeGraphemes::new(&visible_chars) {
275            let (g_width, g_chars) = grapheme_width_and_chars_len(g);        
276            if current_col + g_width > clicked_col { break; }
277            current_col += g_width;
278            char_idx += g_chars;
279        }
280    
281        let line = self.code.char_slice(line_start_char, line_start_char + line_len);
282        let visual_width: usize = RopeGraphemes::new(&line).map(grapheme_width).sum();
283    
284        if clicked_col + self.offset_x >= visual_width {
285            let mut end_idx = line.len_chars();
286            if end_idx > 0 && line.char(end_idx - 1) == '\n' {
287                end_idx -= 1;
288            }
289            char_idx = end_idx;
290        }
291    
292        Some(line_start_char + char_idx)
293    }
294
295    /// Clears any active selection.
296    pub fn clear_selection(&mut self) {
297        self.selection = None;
298    }
299
300    /// Extends or starts a selection from the current cursor to `new_cursor`.
301    pub fn extend_selection(&mut self, new_cursor: usize) {
302        // If there was already a selection, preserve the anchor (start point)
303        // otherwise, use the current cursor as the anchor.
304        let anchor = self.selection_anchor();
305        self.selection = Some(Selection::from_anchor_and_cursor(anchor, new_cursor));
306    }
307    
308    /// Returns the selection anchor position, or the cursor if no selection exists.
309    pub fn selection_anchor(&self) -> usize {
310        self.selection
311            .as_ref()
312            .map(|s| if self.cursor == s.start { s.end } else { s.start })
313            .unwrap_or(self.cursor)
314    }
315
316    pub fn apply<A: Action>(&mut self, mut action: A) {
317        action.apply(self);
318    }
319
320    pub fn set_content(&mut self, content: &str) {
321        self.code.tx();
322        self.code.set_state_before(self.cursor, self.selection);
323        self.code.remove(0, self.code.len());
324        self.code.insert(0, content);
325        self.code.set_state_after(self.cursor, self.selection);
326        self.code.commit();
327        self.reset_highlight_cache();
328    }
329
330    pub fn apply_batch(&mut self, batch: &EditBatch) {
331        self.code.tx();
332
333        if let Some(state) = &batch.state_before {
334            self.code.set_state_before(state.offset, state.selection);
335        }
336        if let Some(state) = &batch.state_after {
337            self.code.set_state_after(state.offset, state.selection);
338        }
339        
340        for edit in &batch.edits {
341            match &edit.kind {
342                EditKind::Insert { offset, text } => {
343                    self.code.insert(*offset, text);
344                }
345                EditKind::Remove { offset, text } => {
346                    self.code.remove(*offset, *offset + text.chars().count());
347                }
348            }
349        }
350        self.code.commit();
351        self.reset_highlight_cache();
352    }
353
354    pub fn set_cursor(&mut self, cursor: usize) {
355        self.cursor = cursor;
356        self.fit_cursor();
357    }
358
359    pub fn fit_cursor(&mut self) {
360        // make sure cursor is not out of bounds 
361        let len = self.code.len_chars();
362        self.cursor = self.cursor.min(len);
363
364        // make sure cursor is not out of bounds on the line
365        let (row, col) = self.code.point(self.cursor);
366        if col > self.code.line_len(row) {
367            self.cursor = self.code.line_to_char(row) + self.code.line_len(row);
368        }
369    }
370
371    pub fn scroll_up(&mut self) {
372        if self.offset_y > 0 {
373            self.offset_y -= 1;
374        }
375    }
376
377    pub fn scroll_down(&mut self, area_height: usize) {
378        let len_lines = self.code.len_lines();
379        if self.offset_y < len_lines.saturating_sub(area_height) {
380            self.offset_y += 1;
381        }
382    }
383
384    fn build_theme(theme: &Vec<(&str, &str)>) -> Theme {
385        theme.into_iter()
386            .map(|(name, hex)| {
387                let (r, g, b) = utils::rgb(hex);
388                (name.to_string(), Style::default().fg(Color::Rgb(r, g, b)))
389            })
390            .collect()
391    }
392
393    pub fn get_content(&self) -> String {
394        self.code.get_content()
395    }
396
397    pub fn get_content_slice(&self, start: usize, end: usize) -> String {
398        self.code.slice(start, end)
399    }
400
401    pub fn get_cursor(&self) -> usize {
402        self.cursor
403    }
404
405    pub fn set_clipboard(&mut self, text: &str) -> Result<()> {
406        arboard::Clipboard::new()
407            .and_then(|mut c| c.set_text(text.to_string()))
408            .unwrap_or_else(|_| self.clipboard = Some(text.to_string()));
409        Ok(())
410    }
411
412    pub fn get_clipboard(&self) -> Result<String> {
413        arboard::Clipboard::new()
414            .and_then(|mut c| c.get_text())
415            .ok()
416            .or_else(|| self.clipboard.clone())
417            .ok_or_else(|| anyhow!("cant get clipboard"))
418    }
419
420    pub fn set_marks(&mut self, marks: Vec<(usize, usize, &str)>) {
421        self.marks = Some(
422            marks.into_iter()
423                .map(|(start, end, color)| {
424                    let (r, g, b) = utils::rgb(color);
425                    (start, end, Color::Rgb(r, g, b))
426                })
427                .collect()
428        );
429    }
430
431    pub fn remove_marks(&mut self) {
432        self.marks = None;
433    }
434
435    pub fn has_marks(&self) -> bool {
436        self.marks.is_some()
437    }
438
439    pub fn get_marks(&self) -> Option<&Vec<(usize, usize, Color)>> {
440        self.marks.as_ref()
441    }
442
443    pub fn get_selection_text(&mut self) -> Option<String> {
444        if let Some(selection) = &self.selection && !selection.is_empty() {
445            let text = self.code.slice(selection.start, selection.end);
446            return Some(text);
447        }
448        None
449    }
450
451    pub fn get_selection(&mut self) -> Option<Selection> {
452       return self.selection;
453    }
454
455    pub fn set_selection(&mut self, selection: Option<Selection>) {
456        self.selection = selection;
457    }
458
459    pub fn set_offset_y(&mut self, offset_y: usize) {
460        self.offset_y = offset_y;
461    }
462
463    pub fn set_offset_x(&mut self, offset_x: usize) {
464        self.offset_x = offset_x;
465    }
466    
467    pub fn get_offset_y(&self) -> usize {
468        self.offset_y
469    }
470
471    pub fn get_offset_x(&self) -> usize {
472        self.offset_x
473    }
474
475    pub fn code_mut(&mut self) -> &mut Code {
476        &mut self.code
477    }
478
479    pub fn code_ref(&self) -> &Code {
480        &self.code
481    }
482
483    /// Set the change callback function for handling document changes
484    pub fn set_change_callback(
485        &mut self, callback: Box<dyn Fn(Vec<(usize, usize, usize, usize, String)>)>
486    ) {
487        self.code.set_change_callback(callback);
488    }
489
490    pub fn highlight_interval(
491        &self, start: usize, end: usize, theme: &Theme
492    ) -> Vec<(usize, usize, Style)> {
493        let mut cache = self.highlights_cache.borrow_mut();
494        let key = (start, end);
495        if let Some(v) = cache.get(&key) {
496            return v.clone();
497        }
498
499        let highlights = self.code.highlight_interval(start, end, theme);
500        cache.insert(key, highlights.clone());
501        highlights
502    }
503
504    pub fn reset_highlight_cache(&self) {
505        self.highlights_cache.borrow_mut().clear();
506    }
507    
508    /// calculates visible cursor position 
509    pub fn get_visible_cursor(
510        &self, area: &Rect
511    ) -> Option<(u16, u16)> {
512        let total_lines = self.code.len_lines();
513        let max_line_number = total_lines.max(1);
514        let line_number_digits = max_line_number.to_string().len().max(5);
515        let line_number_width = line_number_digits + 2;
516
517        let (cursor_line, cursor_char_col) = self.code.point(self.cursor);
518        
519        if cursor_line >= self.offset_y && cursor_line < self.offset_y + area.height as usize {
520            let line_start_char = self.code.line_to_char(cursor_line);
521            let line_len = self.code.line_len(cursor_line);
522        
523            let max_x = (area.width as usize).saturating_sub(line_number_width);
524            let start_col = self.offset_x;
525                
526            let cursor_visual_col: usize = {
527                let slice = self.code.char_slice(line_start_char, line_start_char + cursor_char_col.min(line_len));
528                RopeGraphemes::new(&slice).map(grapheme_width).sum()
529            };
530            
531            let offset_visual_col: usize = {
532                let slice = self.code.char_slice(line_start_char, line_start_char + start_col.min(line_len));
533                RopeGraphemes::new(&slice).map(grapheme_width).sum()
534            };
535        
536            let relative_visual_col = cursor_visual_col.saturating_sub(offset_visual_col);
537            let visible_x = relative_visual_col.min(max_x);
538        
539            let cursor_x = area.left() + (line_number_width + visible_x) as u16;
540            let cursor_y = area.top() + (cursor_line - self.offset_y) as u16;
541        
542            if cursor_x < area.right() && cursor_y < area.bottom() {
543                return Some((cursor_x, cursor_y));
544            }
545        }
546        
547        return None;
548    }
549}