Skip to main content

jev_repl/
builder.rs

1//! Builder mode: compose a question in a form instead of a one-line command, with the JSON it
2//! will send rendered beside it as you type.
3
4use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5use serde_json::{Map, Value};
6use typesafe::{Choice, Noul, Question, Score};
7
8use crate::session::value;
9use crate::words;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Kind {
13    Noul,
14    Choice,
15    Score,
16}
17
18impl Kind {
19    pub fn label(self) -> &'static str {
20        match self {
21            Kind::Noul => "noul",
22            Kind::Choice => "choice",
23            Kind::Score => "score",
24        }
25    }
26
27    pub fn about(self) -> &'static str {
28        match self {
29            Kind::Noul => "probability that the statement is true (0–1)",
30            Kind::Choice => "one label out of the set you define",
31            Kind::Score => "a weighted position along ordered levels",
32        }
33    }
34
35    fn next(self) -> Self {
36        match self {
37            Kind::Noul => Kind::Choice,
38            Kind::Choice => Kind::Score,
39            Kind::Score => Kind::Noul,
40        }
41    }
42
43    fn prev(self) -> Self {
44        self.next().next()
45    }
46}
47
48/// Which widget the keyboard is on.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Field {
51    State,
52    Name,
53    Kind,
54    Instructions,
55    /// A noul's `yes:` / `no:` criteria.
56    Yes,
57    No,
58    /// A choice option: row index, and whether the cursor is in the label or the description.
59    OptionLabel(usize),
60    OptionDesc(usize),
61    /// A score level.
62    Level(usize),
63}
64
65/// What the app should do after a key press.
66pub enum Outcome {
67    /// Stay open.
68    Open,
69    /// Close without adding anything.
70    Cancel,
71    /// Add this question (and any edited state) to the session.
72    Commit(String, Box<Question>, String),
73}
74
75pub struct Builder {
76    pub state: String,
77    pub name: String,
78    pub kind: Kind,
79    pub instructions: String,
80    pub yes: String,
81    pub no: String,
82    pub options: Vec<(String, String)>,
83    pub levels: Vec<String>,
84    pub focus: usize,
85    pub cursor: usize,
86    pub message: Option<String>,
87    /// The names the session already holds, so the form can say what it would overwrite.
88    pub existing: Vec<String>,
89    /// Ctrl-S over one of those names asks once before replacing it.
90    replace_armed: bool,
91}
92
93impl Builder {
94    /// Opens on the first field that still needs an answer: the state if there is none, the name
95    /// if there is no name, otherwise the type.
96    pub fn new(state: String, name: &str) -> Self {
97        let focus = if !name.is_empty() {
98            2
99        } else if state.trim().is_empty() {
100            0
101        } else {
102            1
103        };
104        Self {
105            state: state.clone(),
106            name: name.to_owned(),
107            kind: Kind::Noul,
108            instructions: String::new(),
109            yes: String::new(),
110            no: String::new(),
111            options: vec![
112                (String::new(), String::new()),
113                (String::new(), String::new()),
114            ],
115            levels: vec![String::new(), String::new(), String::new()],
116            focus,
117            cursor: if focus == 0 {
118                state.chars().count()
119            } else {
120                name.chars().count()
121            },
122            message: None,
123            existing: Vec::new(),
124            replace_armed: false,
125        }
126    }
127
128    /// Open on this type rather than a noul.
129    ///
130    /// This is how a second question of the same type costs nothing to reach: after one is added
131    /// the form reopens on the type just used, instead of falling back to a noul every time.
132    pub fn of_kind(mut self, kind: Kind) -> Self {
133        self.kind = kind;
134        self.focus = self.focus.min(self.fields().len() - 1);
135        self
136    }
137
138    /// The names the session already holds; committing over one of them asks first.
139    pub fn over(mut self, existing: Vec<String>) -> Self {
140        self.existing = existing;
141        self
142    }
143
144    /// The focusable fields, in tab order, for the current question type.
145    pub fn fields(&self) -> Vec<Field> {
146        let mut f = vec![Field::State, Field::Name, Field::Kind, Field::Instructions];
147        match self.kind {
148            Kind::Noul => f.extend([Field::Yes, Field::No]),
149            Kind::Choice => {
150                for i in 0..self.options.len() {
151                    f.push(Field::OptionLabel(i));
152                    f.push(Field::OptionDesc(i));
153                }
154            }
155            Kind::Score => f.extend((0..self.levels.len()).map(Field::Level)),
156        }
157        f
158    }
159
160    pub fn focused(&self) -> Field {
161        let fields = self.fields();
162        fields[self.focus.min(fields.len() - 1)]
163    }
164
165    pub fn text(&self, field: Field) -> &str {
166        match field {
167            Field::State => &self.state,
168            Field::Name => &self.name,
169            Field::Instructions => &self.instructions,
170            Field::Yes => &self.yes,
171            Field::No => &self.no,
172            Field::OptionLabel(i) => self.options.get(i).map(|o| o.0.as_str()).unwrap_or(""),
173            Field::OptionDesc(i) => self.options.get(i).map(|o| o.1.as_str()).unwrap_or(""),
174            Field::Level(i) => self.levels.get(i).map(String::as_str).unwrap_or(""),
175            Field::Kind => self.kind.label(),
176        }
177    }
178
179    fn text_mut(&mut self, field: Field) -> Option<&mut String> {
180        match field {
181            Field::State => Some(&mut self.state),
182            Field::Name => Some(&mut self.name),
183            Field::Instructions => Some(&mut self.instructions),
184            Field::Yes => Some(&mut self.yes),
185            Field::No => Some(&mut self.no),
186            Field::OptionLabel(i) => self.options.get_mut(i).map(|o| &mut o.0),
187            Field::OptionDesc(i) => self.options.get_mut(i).map(|o| &mut o.1),
188            Field::Level(i) => self.levels.get_mut(i),
189            Field::Kind => None,
190        }
191    }
192
193    pub fn key(&mut self, key: KeyEvent) -> Outcome {
194        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
195        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
196        self.message = None;
197        // Alt-←/→ cross a word, except on the type row, where ← → switch the type.
198        if self.focused() != Field::Kind
199            && (words::is_word_left(&key) || words::is_word_right(&key))
200        {
201            let chars: Vec<char> = self.text(self.focused()).chars().collect();
202            self.cursor = if words::is_word_left(&key) {
203                words::word_left(&chars, self.cursor)
204            } else {
205                words::word_right(&chars, self.cursor)
206            };
207            return Outcome::Open;
208        }
209        match key.code {
210            KeyCode::Esc => return Outcome::Cancel,
211            KeyCode::Char('s') if ctrl => return self.commit(),
212            KeyCode::Char('x') if ctrl => self.delete_row(),
213            KeyCode::Char('o') if ctrl => self.add_row(),
214            KeyCode::Tab => self.move_focus(1),
215            KeyCode::BackTab => self.move_focus(-1),
216            KeyCode::Down => self.move_focus(1),
217            KeyCode::Up => self.move_focus(-1),
218            KeyCode::Enter => {
219                if self.on_last_row() {
220                    self.add_row();
221                }
222                self.move_focus(1);
223            }
224            KeyCode::Left if self.focused() == Field::Kind => self.set_kind(self.kind.prev()),
225            KeyCode::Right if self.focused() == Field::Kind => self.set_kind(self.kind.next()),
226            KeyCode::Char(' ') if self.focused() == Field::Kind => self.set_kind(self.kind.next()),
227            KeyCode::Char(c) if self.focused() == Field::Kind => match c {
228                'n' | 'N' => self.set_kind(Kind::Noul),
229                'c' | 'C' => self.set_kind(Kind::Choice),
230                's' | 'S' => self.set_kind(Kind::Score),
231                _ => {}
232            },
233            KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
234            KeyCode::Right => self.cursor = (self.cursor + 1).min(self.len()),
235            KeyCode::Home => self.cursor = 0,
236            KeyCode::End => self.cursor = self.len(),
237            KeyCode::Backspace => {
238                if self.cursor > 0 {
239                    let at = self.cursor - 1;
240                    self.cursor = at;
241                    self.remove(at);
242                }
243            }
244            KeyCode::Delete => {
245                let at = self.cursor;
246                self.remove(at);
247            }
248            KeyCode::Char(c) if words::is_typed(&key) => {
249                let _ = shift;
250                self.insert(c);
251            }
252            _ => {}
253        }
254        Outcome::Open
255    }
256
257    fn set_kind(&mut self, kind: Kind) {
258        if kind != self.kind {
259            self.replace_armed = false;
260        }
261        self.kind = kind;
262        self.focus = self.focus.min(self.fields().len() - 1);
263    }
264
265    fn move_focus(&mut self, delta: isize) {
266        let len = self.fields().len() as isize;
267        let next = (self.focus as isize + delta).rem_euclid(len) as usize;
268        self.focus = next;
269        self.cursor = self.len();
270    }
271
272    fn len(&self) -> usize {
273        self.text(self.focused()).chars().count()
274    }
275
276    fn insert(&mut self, c: char) {
277        let cursor = self.cursor;
278        let field = self.focused();
279        if field == Field::Name {
280            self.replace_armed = false;
281        }
282        if let Some(s) = self.text_mut(field) {
283            let at = byte_at(s, cursor);
284            s.insert(at, c);
285            self.cursor = cursor + 1;
286        }
287    }
288
289    fn remove(&mut self, index: usize) {
290        let field = self.focused();
291        if field == Field::Name {
292            self.replace_armed = false;
293        }
294        if let Some(s) = self.text_mut(field)
295            && index < s.chars().count()
296        {
297            let at = byte_at(s, index);
298            s.remove(at);
299        }
300    }
301
302    fn on_last_row(&self) -> bool {
303        match self.focused() {
304            Field::OptionDesc(i) => i + 1 == self.options.len(),
305            Field::Level(i) => i + 1 == self.levels.len(),
306            _ => false,
307        }
308    }
309
310    /// Ctrl-O, or Enter on the last row: one more option or level.
311    pub fn add_row(&mut self) {
312        match self.kind {
313            Kind::Choice => self.options.push((String::new(), String::new())),
314            Kind::Score => self.levels.push(String::new()),
315            Kind::Noul => self.message = Some("A noul has only `yes` and `no`.".into()),
316        }
317    }
318
319    /// Ctrl-X: drop the row the cursor is on.
320    pub fn delete_row(&mut self) {
321        match self.focused() {
322            Field::OptionLabel(i) | Field::OptionDesc(i) if self.options.len() > 1 => {
323                self.options.remove(i);
324            }
325            Field::Level(i) if self.levels.len() > 1 => {
326                self.levels.remove(i);
327            }
328            _ => self.message = Some("Nothing to remove here.".into()),
329        }
330        self.focus = self.focus.min(self.fields().len() - 1);
331        self.cursor = self.len();
332    }
333
334    /// The question as it would go on the wire right now, incomplete parts included.
335    pub fn preview(&self) -> Value {
336        let mut q = Map::new();
337        q.insert("type".into(), Value::String(self.kind.label().into()));
338        if !self.instructions.trim().is_empty() {
339            q.insert("instructions".into(), value(&self.instructions));
340        }
341        match self.kind {
342            Kind::Noul => {
343                let mut criteria = Map::new();
344                if !self.yes.trim().is_empty() {
345                    criteria.insert("true".into(), value(&self.yes));
346                }
347                if !self.no.trim().is_empty() {
348                    criteria.insert("false".into(), value(&self.no));
349                }
350                if !criteria.is_empty() {
351                    q.insert("criteria".into(), Value::Object(criteria));
352                }
353            }
354            Kind::Choice => {
355                let criteria: Map<String, Value> = self
356                    .options
357                    .iter()
358                    .filter(|(label, _)| !label.trim().is_empty())
359                    .map(|(label, desc)| {
360                        let desc = if desc.trim().is_empty() {
361                            Value::Null
362                        } else {
363                            value(desc)
364                        };
365                        (label.trim().to_owned(), desc)
366                    })
367                    .collect();
368                q.insert("criteria".into(), Value::Object(criteria));
369            }
370            Kind::Score => {
371                let criteria: Vec<Value> = self
372                    .levels
373                    .iter()
374                    .filter(|l| !l.trim().is_empty())
375                    .map(|l| value(l))
376                    .collect();
377                q.insert("criteria".into(), Value::Array(criteria));
378            }
379        }
380        let name = if self.name.trim().is_empty() {
381            "<name>"
382        } else {
383            self.name.trim()
384        };
385        serde_json::json!({ name: Value::Object(q) })
386    }
387
388    /// The equivalent one-line command, so builder mode teaches the fast path.
389    pub fn as_command(&self) -> String {
390        let name = if self.name.trim().is_empty() {
391            "<name>"
392        } else {
393            self.name.trim()
394        };
395        let instructions = self.instructions.trim();
396        match self.kind {
397            Kind::Noul => {
398                let mut s = format!(":noul {name} {instructions}");
399                if !self.yes.trim().is_empty() {
400                    s.push_str(&format!(" | yes: {}", self.yes.trim()));
401                }
402                if !self.no.trim().is_empty() {
403                    s.push_str(&format!(" | no: {}", self.no.trim()));
404                }
405                s
406            }
407            Kind::Choice => {
408                let mut s = format!(":choice {name} {instructions}");
409                for (label, desc) in self.options.iter().filter(|(l, _)| !l.trim().is_empty()) {
410                    s.push_str(&format!(" | {}", label.trim()));
411                    if !desc.trim().is_empty() {
412                        s.push_str(&format!("={}", desc.trim()));
413                    }
414                }
415                s
416            }
417            Kind::Score => {
418                let mut s = format!(":score {name} {instructions}");
419                for level in self.levels.iter().filter(|l| !l.trim().is_empty()) {
420                    s.push_str(&format!(" | {}", level.trim()));
421                }
422                s
423            }
424        }
425    }
426
427    fn commit(&mut self) -> Outcome {
428        let name = self.name.trim().to_owned();
429        if name.is_empty() {
430            self.message = Some("Every question needs a name — answers come back under it.".into());
431            return Outcome::Open;
432        }
433        if name.split_whitespace().count() > 1 {
434            self.message = Some("Names cannot contain spaces.".into());
435            return Outcome::Open;
436        }
437        let instructions = self.instructions.trim();
438        if instructions.is_empty() {
439            self.message = Some("Instructions are what the model actually reads.".into());
440            return Outcome::Open;
441        }
442        if self.existing.iter().any(|n| n == &name) && !self.replace_armed {
443            self.replace_armed = true;
444            self.message = Some(format!(
445                "`{name}` is already a question — Ctrl-S again replaces it, or rename this one."
446            ));
447            return Outcome::Open;
448        }
449        let question: Question = match self.kind {
450            Kind::Noul => {
451                let mut q = Noul::new(value(instructions));
452                if !self.yes.trim().is_empty() {
453                    q = q.when_true(value(&self.yes));
454                }
455                if !self.no.trim().is_empty() {
456                    q = q.when_false(value(&self.no));
457                }
458                q.into()
459            }
460            Kind::Choice => {
461                let options: Vec<(String, String)> = self
462                    .options
463                    .iter()
464                    .filter(|(l, _)| !l.trim().is_empty())
465                    .cloned()
466                    .collect();
467                if options.len() < 2 {
468                    self.message = Some("A choice needs at least two options.".into());
469                    return Outcome::Open;
470                }
471                let mut q = Choice::new(value(instructions));
472                for (label, desc) in options {
473                    q = if desc.trim().is_empty() {
474                        q.label(label.trim())
475                    } else {
476                        q.option(label.trim(), value(&desc))
477                    };
478                }
479                q.into()
480            }
481            Kind::Score => {
482                let levels: Vec<Value> = self
483                    .levels
484                    .iter()
485                    .filter(|l| !l.trim().is_empty())
486                    .map(|l| value(l))
487                    .collect();
488                if levels.len() < 2 {
489                    self.message = Some("A score needs at least two ordered levels.".into());
490                    return Outcome::Open;
491                }
492                Score::new(value(instructions), levels).into()
493            }
494        };
495        Outcome::Commit(name, Box::new(question), self.state.clone())
496    }
497}
498
499fn byte_at(s: &str, char_index: usize) -> usize {
500    s.char_indices()
501        .nth(char_index)
502        .map(|(i, _)| i)
503        .unwrap_or(s.len())
504}