tenseleyflow/fackr / 196304d

Browse files

init: phase 1 foundation with basic editing

Authored by espadonne
SHA
196304d4a86c016341abaaba5aa938ffefe5e929
Tree
2b7288b

14 changed files

StatusFile+-
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
+}