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