Skip to main content

jev_repl/
editor.rs

1//! Sketch mode: a small text editor over one page of [`sketch`](crate::sketch) notation. The
2//! page is parsed on every keystroke, so the gutter and the preview pane always show what the
3//! text currently means.
4
5use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
6
7use crate::sketch::{self, Parsed};
8use crate::words;
9
10/// What the right-hand pane shows next to the page.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Preview {
13    /// The request body, exactly as it would be POSTed.
14    Json,
15    /// Simulated answers, so the shape of what comes back is visible while writing.
16    Answers,
17    /// The same request as a program against the SDK.
18    Rust,
19    /// What a call would cost: tokens per question, priced when rates are set.
20    Cost,
21}
22
23impl Preview {
24    pub const ALL: [Preview; 4] = [
25        Preview::Json,
26        Preview::Answers,
27        Preview::Rust,
28        Preview::Cost,
29    ];
30
31    pub fn label(self) -> &'static str {
32        match self {
33            Preview::Json => "json",
34            Preview::Answers => "answers",
35            Preview::Rust => "rust",
36            Preview::Cost => "cost",
37        }
38    }
39
40    fn next(self) -> Self {
41        match self {
42            Preview::Json => Preview::Answers,
43            Preview::Answers => Preview::Rust,
44            Preview::Rust => Preview::Cost,
45            Preview::Cost => Preview::Json,
46        }
47    }
48}
49
50/// What the app should do after a key press.
51pub enum Outcome {
52    /// Stay open.
53    Open,
54    /// Close without touching the session.
55    Cancel,
56    /// Replace the session with the page.
57    Apply,
58    /// Replace the session with the page, then send it.
59    ApplyAndAsk,
60}
61
62pub struct Editor {
63    pub lines: Vec<String>,
64    pub row: usize,
65    /// Column as a character index into the current line.
66    pub col: usize,
67    /// First visible row; the UI adjusts it to keep the cursor in view.
68    pub top: usize,
69    pub preview: Preview,
70    pub dirty: bool,
71    /// Esc on a dirty page asks once before discarding it.
72    esc_armed: bool,
73    /// The last line cut with Ctrl-X, ready for Ctrl-U.
74    cut: Option<String>,
75    pub message: Option<String>,
76}
77
78impl Editor {
79    pub fn new(text: &str) -> Self {
80        let lines: Vec<String> = text.split('\n').map(str::to_owned).collect();
81        Self {
82            lines,
83            row: 0,
84            col: 0,
85            top: 0,
86            preview: Preview::Json,
87            dirty: false,
88            esc_armed: false,
89            cut: None,
90            message: None,
91        }
92    }
93
94    pub fn text(&self) -> String {
95        self.lines.join("\n")
96    }
97
98    pub fn parsed(&self) -> Parsed {
99        sketch::parse(&self.text())
100    }
101
102    pub fn key(&mut self, key: KeyEvent) -> Outcome {
103        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
104        let alt = words::is_alt(&key);
105        self.message = None;
106        let armed = std::mem::take(&mut self.esc_armed);
107        // Alt-←/→ cross a word, Alt-Backspace takes one out; Alt-↑/↓ below move the whole line.
108        if words::is_word_left(&key) {
109            self.word_left();
110            return Outcome::Open;
111        }
112        if words::is_word_right(&key) {
113            self.word_right();
114            return Outcome::Open;
115        }
116        if words::is_delete_word_left(&key) {
117            self.delete_word_left();
118            return Outcome::Open;
119        }
120        match key.code {
121            KeyCode::Esc => {
122                if self.dirty && !armed {
123                    self.esc_armed = true;
124                    self.message =
125                        Some("unapplied edits — Esc again discards them, ^S applies".into());
126                } else {
127                    return Outcome::Cancel;
128                }
129            }
130            KeyCode::Char('s') if ctrl => return Outcome::Apply,
131            KeyCode::Char('g') if ctrl => return Outcome::ApplyAndAsk,
132            KeyCode::Char('p') if ctrl => self.preview = self.preview.next(),
133            KeyCode::Char('x') if ctrl => self.cut_line(),
134            KeyCode::Char('u') if ctrl => self.paste_line(),
135            KeyCode::Char('a') if ctrl => self.col = 0,
136            KeyCode::Char('e') if ctrl => self.col = self.len(),
137            KeyCode::Up if alt => self.swap(-1),
138            KeyCode::Down if alt => self.swap(1),
139            KeyCode::Up => self.vertical(-1),
140            KeyCode::Down => self.vertical(1),
141            KeyCode::PageUp => self.vertical(-10),
142            KeyCode::PageDown => self.vertical(10),
143            KeyCode::Left => {
144                if self.col > 0 {
145                    self.col -= 1;
146                } else if self.row > 0 {
147                    self.row -= 1;
148                    self.col = self.len();
149                }
150            }
151            KeyCode::Right => {
152                if self.col < self.len() {
153                    self.col += 1;
154                } else if self.row + 1 < self.lines.len() {
155                    self.row += 1;
156                    self.col = 0;
157                }
158            }
159            KeyCode::Home => self.col = 0,
160            KeyCode::End => self.col = self.len(),
161            KeyCode::Enter => self.newline(),
162            KeyCode::Tab => {
163                self.insert_str("  ");
164            }
165            KeyCode::Backspace => self.backspace(),
166            KeyCode::Delete => self.delete(),
167            KeyCode::Char(c) if !ctrl && !alt => self.insert(c),
168            _ => {}
169        }
170        Outcome::Open
171    }
172
173    fn len(&self) -> usize {
174        self.lines[self.row].chars().count()
175    }
176
177    fn vertical(&mut self, delta: isize) {
178        let last = self.lines.len() as isize - 1;
179        self.row = (self.row as isize + delta).clamp(0, last) as usize;
180        self.col = self.col.min(self.len());
181    }
182
183    fn insert(&mut self, c: char) {
184        let at = byte_at(&self.lines[self.row], self.col);
185        self.lines[self.row].insert(at, c);
186        self.col += 1;
187        self.dirty = true;
188    }
189
190    pub fn insert_str(&mut self, s: &str) {
191        for c in s.chars() {
192            self.insert(c);
193        }
194    }
195
196    /// Split the line at the cursor; the new line keeps the indentation of the one above.
197    fn newline(&mut self) {
198        let at = byte_at(&self.lines[self.row], self.col);
199        let tail = self.lines[self.row].split_off(at);
200        let indent: String = self.lines[self.row]
201            .chars()
202            .take_while(|c| c.is_whitespace())
203            .collect();
204        let indent = if tail.trim().is_empty() && self.lines[self.row].trim().is_empty() {
205            String::new()
206        } else {
207            indent
208        };
209        self.row += 1;
210        self.col = indent.chars().count();
211        self.lines.insert(self.row, format!("{indent}{tail}"));
212        self.dirty = true;
213    }
214
215    fn backspace(&mut self) {
216        if self.col > 0 {
217            self.col -= 1;
218            let at = byte_at(&self.lines[self.row], self.col);
219            self.lines[self.row].remove(at);
220            self.dirty = true;
221        } else if self.row > 0 {
222            let line = self.lines.remove(self.row);
223            self.row -= 1;
224            self.col = self.len();
225            self.lines[self.row].push_str(&line);
226            self.dirty = true;
227        }
228    }
229
230    fn delete(&mut self) {
231        if self.col < self.len() {
232            let at = byte_at(&self.lines[self.row], self.col);
233            self.lines[self.row].remove(at);
234            self.dirty = true;
235        } else if self.row + 1 < self.lines.len() {
236            let next = self.lines.remove(self.row + 1);
237            self.lines[self.row].push_str(&next);
238            self.dirty = true;
239        }
240    }
241
242    /// Ctrl-X: take the current line out; Ctrl-U puts it back wherever the cursor is.
243    fn cut_line(&mut self) {
244        let line = if self.lines.len() == 1 {
245            std::mem::take(&mut self.lines[0])
246        } else {
247            self.lines.remove(self.row)
248        };
249        self.cut = Some(line);
250        self.row = self.row.min(self.lines.len() - 1);
251        self.col = self.col.min(self.len());
252        self.dirty = true;
253        self.message = Some("line cut — ^U pastes it above the cursor".into());
254    }
255
256    fn paste_line(&mut self) {
257        match self.cut.clone() {
258            Some(line) => {
259                self.lines.insert(self.row, line);
260                self.row += 1;
261                self.dirty = true;
262            }
263            None => self.message = Some("nothing cut yet — ^X cuts the current line".into()),
264        }
265    }
266
267    fn chars(&self) -> Vec<char> {
268        self.lines[self.row].chars().collect()
269    }
270
271    /// Alt-←: to the start of the word before the cursor, or onto the end of the line above.
272    fn word_left(&mut self) {
273        if self.col == 0 {
274            if self.row > 0 {
275                self.row -= 1;
276                self.col = self.len();
277            }
278            return;
279        }
280        self.col = words::word_left(&self.chars(), self.col);
281    }
282
283    /// Alt-→: past the end of the word after the cursor, or onto the start of the line below.
284    fn word_right(&mut self) {
285        if self.col >= self.len() {
286            if self.row + 1 < self.lines.len() {
287                self.row += 1;
288                self.col = 0;
289            }
290            return;
291        }
292        self.col = words::word_right(&self.chars(), self.col);
293    }
294
295    /// Alt-Backspace: take the word before the cursor out.
296    fn delete_word_left(&mut self) {
297        if self.col == 0 {
298            self.backspace();
299            return;
300        }
301        let chars = self.chars();
302        let at = words::word_left(&chars, self.col);
303        let kept: String = chars[..at].iter().chain(chars[self.col..].iter()).collect();
304        self.lines[self.row] = kept;
305        self.col = at;
306        self.dirty = true;
307    }
308
309    /// Alt-Up / Alt-Down: move the current line, which is how questions and levels get reordered.
310    fn swap(&mut self, delta: isize) {
311        let target = self.row as isize + delta;
312        if target < 0 || target >= self.lines.len() as isize {
313            return;
314        }
315        self.lines.swap(self.row, target as usize);
316        self.row = target as usize;
317        self.dirty = true;
318    }
319}
320
321fn byte_at(s: &str, char_index: usize) -> usize {
322    s.char_indices()
323        .nth(char_index)
324        .map(|(i, _)| i)
325        .unwrap_or(s.len())
326}