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