Skip to main content

ratatui_code_editor/
editor.rs

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