init: phase 1 foundation with basic editing
- SHA
196304d4a86c016341abaaba5aa938ffefe5e929- Tree
2b7288b
196304d
196304d4a86c016341abaaba5aa938ffefe5e9292b7288b| Status | File | + | - |
|---|---|---|---|
| A |
.gitignore
|
7 | 0 |
| A |
Cargo.toml
|
23 | 0 |
| A |
src/buffer/mod.rs
|
3 | 0 |
| A |
src/buffer/rope.rs
|
199 | 0 |
| A |
src/editor/cursor.rs
|
26 | 0 |
| A |
src/editor/mod.rs
|
5 | 0 |
| A |
src/editor/state.rs
|
260 | 0 |
| A |
src/input/key.rs
|
66 | 0 |
| A |
src/input/mod.rs
|
3 | 0 |
| A |
src/main.rs
|
22 | 0 |
| A |
src/render/mod.rs
|
3 | 0 |
| A |
src/render/screen.rs
|
164 | 0 |
| A |
src/util/mod.rs
|
3 | 0 |
| A |
src/util/unicode.rs
|
39 | 0 |
.gitignoreadded@@ -0,0 +1,7 @@ | ||
| 1 | +/target | |
| 2 | +Cargo.lock | |
| 3 | +*.swp | |
| 4 | +*.swo | |
| 5 | +*~ | |
| 6 | +.DS_Store | |
| 7 | +/planning/ | |
Cargo.tomladded@@ -0,0 +1,23 @@ | ||
| 1 | +[package] | |
| 2 | +name = "fac" | |
| 3 | +version = "0.1.0" | |
| 4 | +edition = "2021" | |
| 5 | +description = "Terminal text editor" | |
| 6 | +authors = ["Matthew Forrester Wolffe"] | |
| 7 | + | |
| 8 | +[dependencies] | |
| 9 | +# Terminal | |
| 10 | +crossterm = "0.28" | |
| 11 | + | |
| 12 | +# Text handling | |
| 13 | +ropey = "1.6" | |
| 14 | +unicode-segmentation = "1.10" | |
| 15 | +unicode-width = "0.1" | |
| 16 | + | |
| 17 | +# Error handling | |
| 18 | +thiserror = "1" | |
| 19 | +anyhow = "1" | |
| 20 | + | |
| 21 | +[profile.release] | |
| 22 | +opt-level = 3 | |
| 23 | +lto = true | |
src/buffer/mod.rsadded@@ -0,0 +1,3 @@ | ||
| 1 | +mod rope; | |
| 2 | + | |
| 3 | +pub use rope::Buffer; | |
src/buffer/rope.rsadded@@ -0,0 +1,199 @@ | ||
| 1 | +use anyhow::Result; | |
| 2 | +use ropey::Rope; | |
| 3 | +use std::fs::File; | |
| 4 | +use std::io::{BufReader, BufWriter}; | |
| 5 | +use std::path::Path; | |
| 6 | + | |
| 7 | +/// Text buffer using rope data structure for efficient editing | |
| 8 | +#[derive(Debug)] | |
| 9 | +pub struct Buffer { | |
| 10 | + text: Rope, | |
| 11 | + pub modified: bool, | |
| 12 | +} | |
| 13 | + | |
| 14 | +impl Default for Buffer { | |
| 15 | + fn default() -> Self { | |
| 16 | + Self::new() | |
| 17 | + } | |
| 18 | +} | |
| 19 | + | |
| 20 | +impl Buffer { | |
| 21 | + pub fn new() -> Self { | |
| 22 | + Self { | |
| 23 | + text: Rope::new(), | |
| 24 | + modified: false, | |
| 25 | + } | |
| 26 | + } | |
| 27 | + | |
| 28 | + pub fn from_str(s: &str) -> Self { | |
| 29 | + Self { | |
| 30 | + text: Rope::from_str(s), | |
| 31 | + modified: false, | |
| 32 | + } | |
| 33 | + } | |
| 34 | + | |
| 35 | + pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> { | |
| 36 | + let file = File::open(path)?; | |
| 37 | + let reader = BufReader::new(file); | |
| 38 | + let text = Rope::from_reader(reader)?; | |
| 39 | + Ok(Self { | |
| 40 | + text, | |
| 41 | + modified: false, | |
| 42 | + }) | |
| 43 | + } | |
| 44 | + | |
| 45 | + pub fn save<P: AsRef<Path>>(&mut self, path: P) -> Result<()> { | |
| 46 | + let file = File::create(path)?; | |
| 47 | + let writer = BufWriter::new(file); | |
| 48 | + self.text.write_to(writer)?; | |
| 49 | + self.modified = false; | |
| 50 | + Ok(()) | |
| 51 | + } | |
| 52 | + | |
| 53 | + /// Insert text at character index | |
| 54 | + pub fn insert(&mut self, char_idx: usize, text: &str) { | |
| 55 | + let idx = char_idx.min(self.text.len_chars()); | |
| 56 | + self.text.insert(idx, text); | |
| 57 | + self.modified = true; | |
| 58 | + } | |
| 59 | + | |
| 60 | + /// Delete characters in range [start, end) | |
| 61 | + pub fn delete(&mut self, start: usize, end: usize) { | |
| 62 | + let start = start.min(self.text.len_chars()); | |
| 63 | + let end = end.min(self.text.len_chars()); | |
| 64 | + if start < end { | |
| 65 | + self.text.remove(start..end); | |
| 66 | + self.modified = true; | |
| 67 | + } | |
| 68 | + } | |
| 69 | + | |
| 70 | + /// Get total line count | |
| 71 | + pub fn line_count(&self) -> usize { | |
| 72 | + self.text.len_lines() | |
| 73 | + } | |
| 74 | + | |
| 75 | + /// Get total character count | |
| 76 | + pub fn char_count(&self) -> usize { | |
| 77 | + self.text.len_chars() | |
| 78 | + } | |
| 79 | + | |
| 80 | + /// Get a line's content (0-indexed) | |
| 81 | + pub fn line(&self, line_idx: usize) -> Option<ropey::RopeSlice> { | |
| 82 | + if line_idx < self.text.len_lines() { | |
| 83 | + Some(self.text.line(line_idx)) | |
| 84 | + } else { | |
| 85 | + None | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + /// Get line as String (without trailing newline) | |
| 90 | + pub fn line_str(&self, line_idx: usize) -> Option<String> { | |
| 91 | + self.line(line_idx).map(|l| { | |
| 92 | + let s: String = l.chars().collect(); | |
| 93 | + s.trim_end_matches('\n').to_string() | |
| 94 | + }) | |
| 95 | + } | |
| 96 | + | |
| 97 | + /// Get character count for a line (excluding newline) | |
| 98 | + pub fn line_len(&self, line_idx: usize) -> usize { | |
| 99 | + self.line(line_idx) | |
| 100 | + .map(|l| { | |
| 101 | + let len = l.len_chars(); | |
| 102 | + // Subtract 1 for newline if not last line | |
| 103 | + if line_idx + 1 < self.text.len_lines() && len > 0 { | |
| 104 | + len - 1 | |
| 105 | + } else { | |
| 106 | + len | |
| 107 | + } | |
| 108 | + }) | |
| 109 | + .unwrap_or(0) | |
| 110 | + } | |
| 111 | + | |
| 112 | + /// Convert (line, col) to absolute char index | |
| 113 | + pub fn line_col_to_char(&self, line: usize, col: usize) -> usize { | |
| 114 | + if line >= self.text.len_lines() { | |
| 115 | + return self.text.len_chars(); | |
| 116 | + } | |
| 117 | + let line_start = self.text.line_to_char(line); | |
| 118 | + let line_len = self.line_len(line); | |
| 119 | + line_start + col.min(line_len) | |
| 120 | + } | |
| 121 | + | |
| 122 | + /// Convert absolute char index to (line, col) | |
| 123 | + pub fn char_to_line_col(&self, char_idx: usize) -> (usize, usize) { | |
| 124 | + let idx = char_idx.min(self.text.len_chars()); | |
| 125 | + let line = self.text.char_to_line(idx); | |
| 126 | + let line_start = self.text.line_to_char(line); | |
| 127 | + let col = idx - line_start; | |
| 128 | + (line, col) | |
| 129 | + } | |
| 130 | + | |
| 131 | + /// Get character at position | |
| 132 | + pub fn char_at(&self, char_idx: usize) -> Option<char> { | |
| 133 | + if char_idx < self.text.len_chars() { | |
| 134 | + Some(self.text.char(char_idx)) | |
| 135 | + } else { | |
| 136 | + None | |
| 137 | + } | |
| 138 | + } | |
| 139 | + | |
| 140 | + /// Check if buffer is empty | |
| 141 | + pub fn is_empty(&self) -> bool { | |
| 142 | + self.text.len_chars() == 0 | |
| 143 | + } | |
| 144 | + | |
| 145 | + /// Get rope slice for a range | |
| 146 | + pub fn slice(&self, start: usize, end: usize) -> ropey::RopeSlice { | |
| 147 | + let start = start.min(self.text.len_chars()); | |
| 148 | + let end = end.min(self.text.len_chars()); | |
| 149 | + self.text.slice(start..end) | |
| 150 | + } | |
| 151 | +} | |
| 152 | + | |
| 153 | +#[cfg(test)] | |
| 154 | +mod tests { | |
| 155 | + use super::*; | |
| 156 | + | |
| 157 | + #[test] | |
| 158 | + fn test_new_buffer() { | |
| 159 | + let buf = Buffer::new(); | |
| 160 | + assert_eq!(buf.line_count(), 1); | |
| 161 | + assert_eq!(buf.char_count(), 0); | |
| 162 | + } | |
| 163 | + | |
| 164 | + #[test] | |
| 165 | + fn test_insert() { | |
| 166 | + let mut buf = Buffer::new(); | |
| 167 | + buf.insert(0, "Hello"); | |
| 168 | + assert_eq!(buf.line_str(0), Some("Hello".to_string())); | |
| 169 | + assert!(buf.modified); | |
| 170 | + } | |
| 171 | + | |
| 172 | + #[test] | |
| 173 | + fn test_multiline() { | |
| 174 | + let buf = Buffer::from_str("Hello\nWorld\n"); | |
| 175 | + assert_eq!(buf.line_count(), 3); | |
| 176 | + assert_eq!(buf.line_str(0), Some("Hello".to_string())); | |
| 177 | + assert_eq!(buf.line_str(1), Some("World".to_string())); | |
| 178 | + } | |
| 179 | + | |
| 180 | + #[test] | |
| 181 | + fn test_line_col_conversion() { | |
| 182 | + let buf = Buffer::from_str("Hello\nWorld"); | |
| 183 | + assert_eq!(buf.line_col_to_char(0, 0), 0); | |
| 184 | + assert_eq!(buf.line_col_to_char(0, 5), 5); | |
| 185 | + assert_eq!(buf.line_col_to_char(1, 0), 6); | |
| 186 | + assert_eq!(buf.line_col_to_char(1, 3), 9); | |
| 187 | + | |
| 188 | + assert_eq!(buf.char_to_line_col(0), (0, 0)); | |
| 189 | + assert_eq!(buf.char_to_line_col(5), (0, 5)); | |
| 190 | + assert_eq!(buf.char_to_line_col(6), (1, 0)); | |
| 191 | + } | |
| 192 | + | |
| 193 | + #[test] | |
| 194 | + fn test_delete() { | |
| 195 | + let mut buf = Buffer::from_str("Hello World"); | |
| 196 | + buf.delete(5, 11); | |
| 197 | + assert_eq!(buf.line_str(0), Some("Hello".to_string())); | |
| 198 | + } | |
| 199 | +} | |
src/editor/cursor.rsadded@@ -0,0 +1,26 @@ | ||
| 1 | +/// Cursor position (0-indexed line and column) | |
| 2 | +#[derive(Debug, Clone, Copy, Default)] | |
| 3 | +pub struct Cursor { | |
| 4 | + pub line: usize, | |
| 5 | + pub col: usize, | |
| 6 | + /// Desired column for vertical movement | |
| 7 | + pub desired_col: usize, | |
| 8 | +} | |
| 9 | + | |
| 10 | +impl Cursor { | |
| 11 | + pub fn new() -> Self { | |
| 12 | + Self::default() | |
| 13 | + } | |
| 14 | + | |
| 15 | + pub fn set(&mut self, line: usize, col: usize) { | |
| 16 | + self.line = line; | |
| 17 | + self.col = col; | |
| 18 | + self.desired_col = col; | |
| 19 | + } | |
| 20 | + | |
| 21 | + pub fn move_to(&mut self, line: usize, col: usize) { | |
| 22 | + self.line = line; | |
| 23 | + self.col = col; | |
| 24 | + // Don't update desired_col for horizontal moves | |
| 25 | + } | |
| 26 | +} | |
src/editor/mod.rsadded@@ -0,0 +1,5 @@ | ||
| 1 | +mod cursor; | |
| 2 | +mod state; | |
| 3 | + | |
| 4 | +pub use cursor::Cursor; | |
| 5 | +pub use state::Editor; | |
src/editor/state.rsadded@@ -0,0 +1,260 @@ | ||
| 1 | +use anyhow::Result; | |
| 2 | +use crossterm::event::{self, Event, KeyEvent}; | |
| 3 | +use std::path::PathBuf; | |
| 4 | +use std::time::Duration; | |
| 5 | + | |
| 6 | +use crate::buffer::Buffer; | |
| 7 | +use crate::input::{Key, Modifiers}; | |
| 8 | +use crate::render::Screen; | |
| 9 | + | |
| 10 | +use super::Cursor; | |
| 11 | + | |
| 12 | +/// Main editor state | |
| 13 | +pub struct Editor { | |
| 14 | + buffer: Buffer, | |
| 15 | + cursor: Cursor, | |
| 16 | + viewport_line: usize, | |
| 17 | + screen: Screen, | |
| 18 | + filename: Option<PathBuf>, | |
| 19 | + running: bool, | |
| 20 | +} | |
| 21 | + | |
| 22 | +impl Editor { | |
| 23 | + pub fn new() -> Result<Self> { | |
| 24 | + let mut screen = Screen::new()?; | |
| 25 | + screen.enter_raw_mode()?; | |
| 26 | + | |
| 27 | + Ok(Self { | |
| 28 | + buffer: Buffer::new(), | |
| 29 | + cursor: Cursor::new(), | |
| 30 | + viewport_line: 0, | |
| 31 | + screen, | |
| 32 | + filename: None, | |
| 33 | + running: true, | |
| 34 | + }) | |
| 35 | + } | |
| 36 | + | |
| 37 | + pub fn open(&mut self, path: &str) -> Result<()> { | |
| 38 | + self.buffer = Buffer::load(path)?; | |
| 39 | + self.filename = Some(PathBuf::from(path)); | |
| 40 | + self.cursor = Cursor::new(); | |
| 41 | + self.viewport_line = 0; | |
| 42 | + Ok(()) | |
| 43 | + } | |
| 44 | + | |
| 45 | + pub fn run(&mut self) -> Result<()> { | |
| 46 | + while self.running { | |
| 47 | + self.screen.refresh_size()?; | |
| 48 | + self.render()?; | |
| 49 | + | |
| 50 | + if event::poll(Duration::from_millis(100))? { | |
| 51 | + if let Event::Key(key_event) = event::read()? { | |
| 52 | + self.handle_key(key_event)?; | |
| 53 | + } | |
| 54 | + } | |
| 55 | + } | |
| 56 | + | |
| 57 | + self.screen.leave_raw_mode()?; | |
| 58 | + Ok(()) | |
| 59 | + } | |
| 60 | + | |
| 61 | + fn render(&mut self) -> Result<()> { | |
| 62 | + self.screen.render( | |
| 63 | + &self.buffer, | |
| 64 | + &self.cursor, | |
| 65 | + self.viewport_line, | |
| 66 | + self.filename.as_ref().and_then(|p| p.to_str()), | |
| 67 | + ) | |
| 68 | + } | |
| 69 | + | |
| 70 | + fn handle_key(&mut self, key_event: KeyEvent) -> Result<()> { | |
| 71 | + let (key, mods) = Key::from_crossterm(key_event); | |
| 72 | + | |
| 73 | + match (&key, &mods) { | |
| 74 | + // Quit: Ctrl+Q | |
| 75 | + (Key::Char('q'), Modifiers { ctrl: true, .. }) => { | |
| 76 | + self.running = false; | |
| 77 | + } | |
| 78 | + | |
| 79 | + // Save: Ctrl+S | |
| 80 | + (Key::Char('s'), Modifiers { ctrl: true, .. }) => { | |
| 81 | + self.save()?; | |
| 82 | + } | |
| 83 | + | |
| 84 | + // Movement | |
| 85 | + (Key::Up, _) => self.move_up(), | |
| 86 | + (Key::Down, _) => self.move_down(), | |
| 87 | + (Key::Left, _) => self.move_left(), | |
| 88 | + (Key::Right, _) => self.move_right(), | |
| 89 | + (Key::Home, _) | (Key::Char('a'), Modifiers { ctrl: true, .. }) => self.move_home(), | |
| 90 | + (Key::End, _) | (Key::Char('e'), Modifiers { ctrl: true, .. }) => self.move_end(), | |
| 91 | + (Key::PageUp, _) => self.page_up(), | |
| 92 | + (Key::PageDown, _) => self.page_down(), | |
| 93 | + | |
| 94 | + // Editing | |
| 95 | + (Key::Char(c), Modifiers { ctrl: false, alt: false, .. }) => { | |
| 96 | + self.insert_char(*c); | |
| 97 | + } | |
| 98 | + (Key::Enter, _) => self.insert_newline(), | |
| 99 | + (Key::Backspace, _) | (Key::Char('h'), Modifiers { ctrl: true, .. }) => { | |
| 100 | + self.delete_backward(); | |
| 101 | + } | |
| 102 | + (Key::Delete, _) => self.delete_forward(), | |
| 103 | + (Key::Tab, Modifiers { shift: false, .. }) => self.insert_tab(), | |
| 104 | + | |
| 105 | + _ => {} | |
| 106 | + } | |
| 107 | + | |
| 108 | + self.scroll_to_cursor(); | |
| 109 | + Ok(()) | |
| 110 | + } | |
| 111 | + | |
| 112 | + // === Movement === | |
| 113 | + | |
| 114 | + fn move_up(&mut self) { | |
| 115 | + if self.cursor.line > 0 { | |
| 116 | + self.cursor.line -= 1; | |
| 117 | + let line_len = self.buffer.line_len(self.cursor.line); | |
| 118 | + self.cursor.col = self.cursor.desired_col.min(line_len); | |
| 119 | + } | |
| 120 | + } | |
| 121 | + | |
| 122 | + fn move_down(&mut self) { | |
| 123 | + if self.cursor.line + 1 < self.buffer.line_count() { | |
| 124 | + self.cursor.line += 1; | |
| 125 | + let line_len = self.buffer.line_len(self.cursor.line); | |
| 126 | + self.cursor.col = self.cursor.desired_col.min(line_len); | |
| 127 | + } | |
| 128 | + } | |
| 129 | + | |
| 130 | + fn move_left(&mut self) { | |
| 131 | + if self.cursor.col > 0 { | |
| 132 | + self.cursor.col -= 1; | |
| 133 | + self.cursor.desired_col = self.cursor.col; | |
| 134 | + } else if self.cursor.line > 0 { | |
| 135 | + self.cursor.line -= 1; | |
| 136 | + self.cursor.col = self.buffer.line_len(self.cursor.line); | |
| 137 | + self.cursor.desired_col = self.cursor.col; | |
| 138 | + } | |
| 139 | + } | |
| 140 | + | |
| 141 | + fn move_right(&mut self) { | |
| 142 | + let line_len = self.buffer.line_len(self.cursor.line); | |
| 143 | + if self.cursor.col < line_len { | |
| 144 | + self.cursor.col += 1; | |
| 145 | + self.cursor.desired_col = self.cursor.col; | |
| 146 | + } else if self.cursor.line + 1 < self.buffer.line_count() { | |
| 147 | + self.cursor.line += 1; | |
| 148 | + self.cursor.col = 0; | |
| 149 | + self.cursor.desired_col = 0; | |
| 150 | + } | |
| 151 | + } | |
| 152 | + | |
| 153 | + fn move_home(&mut self) { | |
| 154 | + self.cursor.col = 0; | |
| 155 | + self.cursor.desired_col = 0; | |
| 156 | + } | |
| 157 | + | |
| 158 | + fn move_end(&mut self) { | |
| 159 | + self.cursor.col = self.buffer.line_len(self.cursor.line); | |
| 160 | + self.cursor.desired_col = self.cursor.col; | |
| 161 | + } | |
| 162 | + | |
| 163 | + fn page_up(&mut self) { | |
| 164 | + let page = self.screen.rows.saturating_sub(2) as usize; | |
| 165 | + self.cursor.line = self.cursor.line.saturating_sub(page); | |
| 166 | + let line_len = self.buffer.line_len(self.cursor.line); | |
| 167 | + self.cursor.col = self.cursor.desired_col.min(line_len); | |
| 168 | + } | |
| 169 | + | |
| 170 | + fn page_down(&mut self) { | |
| 171 | + let page = self.screen.rows.saturating_sub(2) as usize; | |
| 172 | + self.cursor.line = (self.cursor.line + page).min(self.buffer.line_count().saturating_sub(1)); | |
| 173 | + let line_len = self.buffer.line_len(self.cursor.line); | |
| 174 | + self.cursor.col = self.cursor.desired_col.min(line_len); | |
| 175 | + } | |
| 176 | + | |
| 177 | + // === Editing === | |
| 178 | + | |
| 179 | + fn insert_char(&mut self, c: char) { | |
| 180 | + let idx = self.buffer.line_col_to_char(self.cursor.line, self.cursor.col); | |
| 181 | + self.buffer.insert(idx, &c.to_string()); | |
| 182 | + self.cursor.col += 1; | |
| 183 | + self.cursor.desired_col = self.cursor.col; | |
| 184 | + } | |
| 185 | + | |
| 186 | + fn insert_newline(&mut self) { | |
| 187 | + let idx = self.buffer.line_col_to_char(self.cursor.line, self.cursor.col); | |
| 188 | + self.buffer.insert(idx, "\n"); | |
| 189 | + self.cursor.line += 1; | |
| 190 | + self.cursor.col = 0; | |
| 191 | + self.cursor.desired_col = 0; | |
| 192 | + } | |
| 193 | + | |
| 194 | + fn insert_tab(&mut self) { | |
| 195 | + let idx = self.buffer.line_col_to_char(self.cursor.line, self.cursor.col); | |
| 196 | + self.buffer.insert(idx, " "); | |
| 197 | + self.cursor.col += 4; | |
| 198 | + self.cursor.desired_col = self.cursor.col; | |
| 199 | + } | |
| 200 | + | |
| 201 | + fn delete_backward(&mut self) { | |
| 202 | + if self.cursor.col > 0 { | |
| 203 | + let idx = self.buffer.line_col_to_char(self.cursor.line, self.cursor.col); | |
| 204 | + self.buffer.delete(idx - 1, idx); | |
| 205 | + self.cursor.col -= 1; | |
| 206 | + self.cursor.desired_col = self.cursor.col; | |
| 207 | + } else if self.cursor.line > 0 { | |
| 208 | + // Join with previous line | |
| 209 | + let prev_line_len = self.buffer.line_len(self.cursor.line - 1); | |
| 210 | + let idx = self.buffer.line_col_to_char(self.cursor.line, 0); | |
| 211 | + self.buffer.delete(idx - 1, idx); // Delete the newline | |
| 212 | + self.cursor.line -= 1; | |
| 213 | + self.cursor.col = prev_line_len; | |
| 214 | + self.cursor.desired_col = self.cursor.col; | |
| 215 | + } | |
| 216 | + } | |
| 217 | + | |
| 218 | + fn delete_forward(&mut self) { | |
| 219 | + let line_len = self.buffer.line_len(self.cursor.line); | |
| 220 | + let idx = self.buffer.line_col_to_char(self.cursor.line, self.cursor.col); | |
| 221 | + | |
| 222 | + if self.cursor.col < line_len { | |
| 223 | + self.buffer.delete(idx, idx + 1); | |
| 224 | + } else if self.cursor.line + 1 < self.buffer.line_count() { | |
| 225 | + // Delete newline, joining with next line | |
| 226 | + self.buffer.delete(idx, idx + 1); | |
| 227 | + } | |
| 228 | + } | |
| 229 | + | |
| 230 | + // === Viewport === | |
| 231 | + | |
| 232 | + fn scroll_to_cursor(&mut self) { | |
| 233 | + let visible_rows = self.screen.rows.saturating_sub(1) as usize; | |
| 234 | + | |
| 235 | + // Scroll up if cursor above viewport | |
| 236 | + if self.cursor.line < self.viewport_line { | |
| 237 | + self.viewport_line = self.cursor.line; | |
| 238 | + } | |
| 239 | + | |
| 240 | + // Scroll down if cursor below viewport | |
| 241 | + if self.cursor.line >= self.viewport_line + visible_rows { | |
| 242 | + self.viewport_line = self.cursor.line - visible_rows + 1; | |
| 243 | + } | |
| 244 | + } | |
| 245 | + | |
| 246 | + // === File operations === | |
| 247 | + | |
| 248 | + fn save(&mut self) -> Result<()> { | |
| 249 | + if let Some(ref path) = self.filename { | |
| 250 | + self.buffer.save(path)?; | |
| 251 | + } | |
| 252 | + Ok(()) | |
| 253 | + } | |
| 254 | +} | |
| 255 | + | |
| 256 | +impl Drop for Editor { | |
| 257 | + fn drop(&mut self) { | |
| 258 | + let _ = self.screen.leave_raw_mode(); | |
| 259 | + } | |
| 260 | +} | |
src/input/key.rsadded@@ -0,0 +1,66 @@ | ||
| 1 | +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; | |
| 2 | + | |
| 3 | +/// Key modifiers | |
| 4 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 5 | +pub struct Modifiers { | |
| 6 | + pub ctrl: bool, | |
| 7 | + pub alt: bool, | |
| 8 | + pub shift: bool, | |
| 9 | +} | |
| 10 | + | |
| 11 | +impl From<KeyModifiers> for Modifiers { | |
| 12 | + fn from(m: KeyModifiers) -> Self { | |
| 13 | + Self { | |
| 14 | + ctrl: m.contains(KeyModifiers::CONTROL), | |
| 15 | + alt: m.contains(KeyModifiers::ALT), | |
| 16 | + shift: m.contains(KeyModifiers::SHIFT), | |
| 17 | + } | |
| 18 | + } | |
| 19 | +} | |
| 20 | + | |
| 21 | +/// Abstracted key input | |
| 22 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 23 | +pub enum Key { | |
| 24 | + Char(char), | |
| 25 | + Backspace, | |
| 26 | + Delete, | |
| 27 | + Enter, | |
| 28 | + Tab, | |
| 29 | + Escape, | |
| 30 | + Up, | |
| 31 | + Down, | |
| 32 | + Left, | |
| 33 | + Right, | |
| 34 | + Home, | |
| 35 | + End, | |
| 36 | + PageUp, | |
| 37 | + PageDown, | |
| 38 | + F(u8), | |
| 39 | + Null, | |
| 40 | +} | |
| 41 | + | |
| 42 | +impl Key { | |
| 43 | + pub fn from_crossterm(event: KeyEvent) -> (Self, Modifiers) { | |
| 44 | + let modifiers = Modifiers::from(event.modifiers); | |
| 45 | + let key = match event.code { | |
| 46 | + KeyCode::Char(c) => Key::Char(c), | |
| 47 | + KeyCode::Backspace => Key::Backspace, | |
| 48 | + KeyCode::Delete => Key::Delete, | |
| 49 | + KeyCode::Enter => Key::Enter, | |
| 50 | + KeyCode::Tab => Key::Tab, | |
| 51 | + KeyCode::Esc => Key::Escape, | |
| 52 | + KeyCode::Up => Key::Up, | |
| 53 | + KeyCode::Down => Key::Down, | |
| 54 | + KeyCode::Left => Key::Left, | |
| 55 | + KeyCode::Right => Key::Right, | |
| 56 | + KeyCode::Home => Key::Home, | |
| 57 | + KeyCode::End => Key::End, | |
| 58 | + KeyCode::PageUp => Key::PageUp, | |
| 59 | + KeyCode::PageDown => Key::PageDown, | |
| 60 | + KeyCode::F(n) => Key::F(n), | |
| 61 | + KeyCode::Null => Key::Null, | |
| 62 | + _ => Key::Null, | |
| 63 | + }; | |
| 64 | + (key, modifiers) | |
| 65 | + } | |
| 66 | +} | |
src/input/mod.rsadded@@ -0,0 +1,3 @@ | ||
| 1 | +mod key; | |
| 2 | + | |
| 3 | +pub use key::{Key, Modifiers}; | |
src/main.rsadded@@ -0,0 +1,22 @@ | ||
| 1 | +mod buffer; | |
| 2 | +mod editor; | |
| 3 | +mod input; | |
| 4 | +mod render; | |
| 5 | +mod util; | |
| 6 | + | |
| 7 | +use anyhow::Result; | |
| 8 | +use editor::Editor; | |
| 9 | +use std::env; | |
| 10 | + | |
| 11 | +fn main() -> Result<()> { | |
| 12 | + let args: Vec<String> = env::args().collect(); | |
| 13 | + let filename = args.get(1).map(|s| s.as_str()); | |
| 14 | + | |
| 15 | + let mut editor = Editor::new()?; | |
| 16 | + | |
| 17 | + if let Some(path) = filename { | |
| 18 | + editor.open(path)?; | |
| 19 | + } | |
| 20 | + | |
| 21 | + editor.run() | |
| 22 | +} | |
src/render/mod.rsadded@@ -0,0 +1,3 @@ | ||
| 1 | +mod screen; | |
| 2 | + | |
| 3 | +pub use screen::Screen; | |
src/render/screen.rsadded@@ -0,0 +1,164 @@ | ||
| 1 | +use anyhow::Result; | |
| 2 | +use crossterm::{ | |
| 3 | + cursor::{Hide, MoveTo, Show}, | |
| 4 | + execute, | |
| 5 | + style::{Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor}, | |
| 6 | + terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, | |
| 7 | +}; | |
| 8 | +use std::io::{stdout, Stdout, Write}; | |
| 9 | + | |
| 10 | +use crate::buffer::Buffer; | |
| 11 | +use crate::editor::Cursor; | |
| 12 | + | |
| 13 | +/// Terminal screen renderer | |
| 14 | +pub struct Screen { | |
| 15 | + stdout: Stdout, | |
| 16 | + pub rows: u16, | |
| 17 | + pub cols: u16, | |
| 18 | +} | |
| 19 | + | |
| 20 | +impl Screen { | |
| 21 | + pub fn new() -> Result<Self> { | |
| 22 | + let (cols, rows) = terminal::size()?; | |
| 23 | + Ok(Self { | |
| 24 | + stdout: stdout(), | |
| 25 | + rows, | |
| 26 | + cols, | |
| 27 | + }) | |
| 28 | + } | |
| 29 | + | |
| 30 | + pub fn enter_raw_mode(&mut self) -> Result<()> { | |
| 31 | + terminal::enable_raw_mode()?; | |
| 32 | + execute!(self.stdout, EnterAlternateScreen, Hide)?; | |
| 33 | + Ok(()) | |
| 34 | + } | |
| 35 | + | |
| 36 | + pub fn leave_raw_mode(&mut self) -> Result<()> { | |
| 37 | + execute!(self.stdout, Show, LeaveAlternateScreen)?; | |
| 38 | + terminal::disable_raw_mode()?; | |
| 39 | + Ok(()) | |
| 40 | + } | |
| 41 | + | |
| 42 | + pub fn refresh_size(&mut self) -> Result<()> { | |
| 43 | + let (cols, rows) = terminal::size()?; | |
| 44 | + self.cols = cols; | |
| 45 | + self.rows = rows; | |
| 46 | + Ok(()) | |
| 47 | + } | |
| 48 | + | |
| 49 | + pub fn clear(&mut self) -> Result<()> { | |
| 50 | + execute!(self.stdout, Clear(ClearType::All))?; | |
| 51 | + Ok(()) | |
| 52 | + } | |
| 53 | + | |
| 54 | + /// Render the editor view | |
| 55 | + pub fn render( | |
| 56 | + &mut self, | |
| 57 | + buffer: &Buffer, | |
| 58 | + cursor: &Cursor, | |
| 59 | + viewport_line: usize, | |
| 60 | + filename: Option<&str>, | |
| 61 | + ) -> Result<()> { | |
| 62 | + let line_num_width = self.line_number_width(buffer.line_count()); | |
| 63 | + let text_cols = self.cols as usize - line_num_width - 1; // -1 for separator | |
| 64 | + | |
| 65 | + // Reserve 1 row for status bar | |
| 66 | + let text_rows = self.rows.saturating_sub(1) as usize; | |
| 67 | + | |
| 68 | + // Draw text area | |
| 69 | + for row in 0..text_rows { | |
| 70 | + let line_idx = viewport_line + row; | |
| 71 | + execute!(self.stdout, MoveTo(0, row as u16))?; | |
| 72 | + | |
| 73 | + if line_idx < buffer.line_count() { | |
| 74 | + // Line number | |
| 75 | + execute!( | |
| 76 | + self.stdout, | |
| 77 | + SetForegroundColor(Color::DarkGrey), | |
| 78 | + Print(format!("{:>width$} ", line_idx + 1, width = line_num_width)), | |
| 79 | + ResetColor | |
| 80 | + )?; | |
| 81 | + | |
| 82 | + // Line content | |
| 83 | + if let Some(line) = buffer.line_str(line_idx) { | |
| 84 | + let visible: String = line.chars().take(text_cols).collect(); | |
| 85 | + execute!(self.stdout, Print(&visible))?; | |
| 86 | + } | |
| 87 | + } else { | |
| 88 | + // Empty line indicator | |
| 89 | + execute!( | |
| 90 | + self.stdout, | |
| 91 | + SetForegroundColor(Color::DarkBlue), | |
| 92 | + Print(format!("{:>width$} ", "~", width = line_num_width)), | |
| 93 | + ResetColor | |
| 94 | + )?; | |
| 95 | + } | |
| 96 | + | |
| 97 | + // Clear rest of line | |
| 98 | + execute!(self.stdout, Clear(ClearType::UntilNewLine))?; | |
| 99 | + } | |
| 100 | + | |
| 101 | + // Status bar | |
| 102 | + self.render_status_bar(buffer, cursor, filename)?; | |
| 103 | + | |
| 104 | + // Position cursor | |
| 105 | + let cursor_row = cursor.line.saturating_sub(viewport_line); | |
| 106 | + let cursor_col = line_num_width + 1 + cursor.col; | |
| 107 | + execute!( | |
| 108 | + self.stdout, | |
| 109 | + MoveTo(cursor_col as u16, cursor_row as u16), | |
| 110 | + Show | |
| 111 | + )?; | |
| 112 | + | |
| 113 | + self.stdout.flush()?; | |
| 114 | + Ok(()) | |
| 115 | + } | |
| 116 | + | |
| 117 | + fn render_status_bar( | |
| 118 | + &mut self, | |
| 119 | + buffer: &Buffer, | |
| 120 | + cursor: &Cursor, | |
| 121 | + filename: Option<&str>, | |
| 122 | + ) -> Result<()> { | |
| 123 | + let status_row = self.rows.saturating_sub(1); | |
| 124 | + execute!(self.stdout, MoveTo(0, status_row))?; | |
| 125 | + | |
| 126 | + // Status bar background | |
| 127 | + execute!( | |
| 128 | + self.stdout, | |
| 129 | + SetBackgroundColor(Color::DarkGrey), | |
| 130 | + SetForegroundColor(Color::White) | |
| 131 | + )?; | |
| 132 | + | |
| 133 | + // Left side: filename + modified indicator | |
| 134 | + let name = filename.unwrap_or("[No Name]"); | |
| 135 | + let modified = if buffer.modified { " [+]" } else { "" }; | |
| 136 | + let left = format!(" {}{}", name, modified); | |
| 137 | + | |
| 138 | + // Right side: position | |
| 139 | + let right = format!(" Ln {}, Col {} ", cursor.line + 1, cursor.col + 1); | |
| 140 | + | |
| 141 | + // Pad middle | |
| 142 | + let padding = self.cols as usize - left.len() - right.len(); | |
| 143 | + let middle = " ".repeat(padding.max(0)); | |
| 144 | + | |
| 145 | + execute!( | |
| 146 | + self.stdout, | |
| 147 | + Print(&left), | |
| 148 | + Print(&middle), | |
| 149 | + Print(&right), | |
| 150 | + ResetColor | |
| 151 | + )?; | |
| 152 | + | |
| 153 | + Ok(()) | |
| 154 | + } | |
| 155 | + | |
| 156 | + fn line_number_width(&self, line_count: usize) -> usize { | |
| 157 | + let digits = if line_count == 0 { | |
| 158 | + 1 | |
| 159 | + } else { | |
| 160 | + (line_count as f64).log10().floor() as usize + 1 | |
| 161 | + }; | |
| 162 | + digits.max(3) // Minimum 3 characters | |
| 163 | + } | |
| 164 | +} | |
src/util/mod.rsadded@@ -0,0 +1,3 @@ | ||
| 1 | +pub mod unicode; | |
| 2 | + | |
| 3 | +pub use unicode::*; | |
src/util/unicode.rsadded@@ -0,0 +1,39 @@ | ||
| 1 | +use unicode_segmentation::UnicodeSegmentation; | |
| 2 | +use unicode_width::UnicodeWidthStr; | |
| 3 | + | |
| 4 | +/// Get the display width of a string (handling wide chars like CJK) | |
| 5 | +pub fn display_width(s: &str) -> usize { | |
| 6 | + UnicodeWidthStr::width(s) | |
| 7 | +} | |
| 8 | + | |
| 9 | +/// Count grapheme clusters in a string | |
| 10 | +pub fn grapheme_count(s: &str) -> usize { | |
| 11 | + s.graphemes(true).count() | |
| 12 | +} | |
| 13 | + | |
| 14 | +/// Get the nth grapheme from a string | |
| 15 | +pub fn nth_grapheme(s: &str, n: usize) -> Option<&str> { | |
| 16 | + s.graphemes(true).nth(n) | |
| 17 | +} | |
| 18 | + | |
| 19 | +/// Convert a grapheme index to byte offset | |
| 20 | +pub fn grapheme_to_byte_offset(s: &str, grapheme_idx: usize) -> usize { | |
| 21 | + s.graphemes(true) | |
| 22 | + .take(grapheme_idx) | |
| 23 | + .map(|g| g.len()) | |
| 24 | + .sum() | |
| 25 | +} | |
| 26 | + | |
| 27 | +/// Convert a byte offset to grapheme index | |
| 28 | +pub fn byte_to_grapheme_offset(s: &str, byte_idx: usize) -> usize { | |
| 29 | + let mut count = 0; | |
| 30 | + let mut bytes = 0; | |
| 31 | + for g in s.graphemes(true) { | |
| 32 | + if bytes >= byte_idx { | |
| 33 | + break; | |
| 34 | + } | |
| 35 | + bytes += g.len(); | |
| 36 | + count += 1; | |
| 37 | + } | |
| 38 | + count | |
| 39 | +} | |