Skip to main content

cerno_tui/
editor.rs

1//! The modal for adding or changing one question.
2//!
3//! Every field is a `TextArea`, including the single-line ones. A second input library would buy
4//! nothing here and cost a dependency: a one-line `TextArea` that ignores Enter already gives
5//! correct cursor movement and Unicode handling.
6
7use crate::draft::{Kind, QuestionDraft};
8use ratatui::crossterm::event::{KeyCode, KeyEvent};
9use ratatui_textarea::{CursorMove, TextArea};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Field {
13    Id,
14    Kind,
15    Question,
16    Options,
17    Levels,
18}
19
20impl Field {
21    pub fn label(self) -> &'static str {
22        match self {
23            Field::Id => "id",
24            Field::Kind => "type",
25            Field::Question => "question",
26            Field::Options => "options",
27            Field::Levels => "levels",
28        }
29    }
30}
31
32pub struct Editor {
33    kind: Kind,
34    /// `Some(index)` when changing an existing question, `None` when adding one.
35    existing: Option<usize>,
36    field: Field,
37    /// The id to use when the field is left blank, shown as a hint on the field.
38    fallback_id: String,
39
40    id: TextArea<'static>,
41    question: TextArea<'static>,
42    options: TextArea<'static>,
43    levels: TextArea<'static>,
44}
45
46impl Editor {
47    /// The rubric used when the levels field is left blank.
48    pub const DEFAULT_LEVELS: &'static str = "5";
49
50    /// A new question. The id and levels fields start **empty**, with their fallbacks offered
51    /// as hints on the field itself.
52    ///
53    /// Pre-filling a field and leaving the cursor in it is what turned a typed "urgent" into
54    /// "urgentq1" and a typed rubric into "5unkritisch, gering, …". Both were found by driving
55    /// the real binary, and both are the same mistake: a default belongs beside the field, not
56    /// inside it. An empty field types cleanly, and leaving it blank still gives something
57    /// usable.
58    pub fn adding(fallback_id: String) -> Self {
59        Self::new(
60            QuestionDraft {
61                levels: String::new(),
62                ..Default::default()
63            },
64            None,
65            fallback_id,
66        )
67    }
68
69    pub fn editing(draft: QuestionDraft, index: usize) -> Self {
70        let fallback = draft.id.clone();
71        Self::new(draft, Some(index), fallback)
72    }
73
74    fn new(draft: QuestionDraft, existing: Option<usize>, fallback_id: String) -> Self {
75        Self {
76            kind: draft.kind,
77            existing,
78            field: Field::Id,
79            fallback_id,
80            id: line(&draft.id),
81            question: line(&draft.question),
82            options: line(&draft.options),
83            levels: line(&draft.levels),
84        }
85    }
86
87    /// The id that will be used if the field is left blank.
88    pub fn fallback_id(&self) -> &str {
89        &self.fallback_id
90    }
91
92    /// Whether `field` is empty and will therefore fall back to its default.
93    pub fn is_blank(&self, field: Field) -> bool {
94        self.field(field).lines().concat().trim().is_empty()
95    }
96
97    pub fn kind(&self) -> Kind {
98        self.kind
99    }
100
101    pub fn focused(&self) -> Field {
102        self.field
103    }
104
105    pub fn is_editing(&self) -> bool {
106        self.existing.is_some()
107    }
108
109    /// The fields this kind actually uses, in tab order.
110    ///
111    /// Options belong to a choice and levels to a score, so tabbing skips the one that would do
112    /// nothing. The text behind it is kept either way — see `QuestionDraft`.
113    pub fn fields(&self) -> Vec<Field> {
114        let mut fields = vec![Field::Id, Field::Kind, Field::Question];
115        match self.kind {
116            Kind::Noul => {}
117            Kind::Choice => fields.push(Field::Options),
118            Kind::Score => fields.push(Field::Levels),
119        }
120        fields
121    }
122
123    pub fn field(&self, field: Field) -> &TextArea<'static> {
124        match field {
125            Field::Id => &self.id,
126            Field::Question => &self.question,
127            Field::Options => &self.options,
128            Field::Levels => &self.levels,
129            Field::Kind => &self.id, // not a text field; never drawn through this path
130        }
131    }
132
133    /// Replace a field's contents outright.
134    pub fn set(&mut self, field: Field, value: &str) {
135        let target = match field {
136            Field::Id => &mut self.id,
137            Field::Question => &mut self.question,
138            Field::Options => &mut self.options,
139            Field::Levels => &mut self.levels,
140            Field::Kind => return,
141        };
142        *target = line(value);
143    }
144
145    pub fn focus_next(&mut self) {
146        let fields = self.fields();
147        let index = fields.iter().position(|f| *f == self.field).unwrap_or(0);
148        self.field = fields[(index + 1) % fields.len()];
149    }
150
151    pub fn focus_previous(&mut self) {
152        let fields = self.fields();
153        let index = fields.iter().position(|f| *f == self.field).unwrap_or(0);
154        self.field = fields[(index + fields.len() - 1) % fields.len()];
155    }
156
157    pub fn cycle_kind(&mut self, forward: bool) {
158        self.kind = if forward {
159            self.kind.next()
160        } else {
161            self.kind.previous()
162        };
163
164        // The field that was focused may no longer exist under the new kind.
165        if !self.fields().contains(&self.field) {
166            self.field = Field::Kind;
167        }
168    }
169
170    /// Feed a key to the focused field. Returns false when the key was not consumed, so the
171    /// caller can treat it as a shortcut.
172    pub fn input(&mut self, key: KeyEvent) -> bool {
173        match key.code {
174            KeyCode::Tab | KeyCode::Down => {
175                self.focus_next();
176                true
177            }
178            KeyCode::BackTab | KeyCode::Up => {
179                self.focus_previous();
180                true
181            }
182            KeyCode::Left | KeyCode::Right if self.field == Field::Kind => {
183                self.cycle_kind(key.code == KeyCode::Right);
184                true
185            }
186            // Enter commits the modal rather than inserting a newline; these are single-line
187            // fields and a hidden second line would be invisible and confusing.
188            KeyCode::Enter => false,
189            _ => {
190                let target = match self.field {
191                    Field::Id => &mut self.id,
192                    Field::Question => &mut self.question,
193                    Field::Options => &mut self.options,
194                    Field::Levels => &mut self.levels,
195                    Field::Kind => return false,
196                };
197                target.input(key)
198            }
199        }
200    }
201
202    /// The draft as typed, plus the slot it belongs in.
203    pub fn finish(self) -> (QuestionDraft, Option<usize>) {
204        let typed_id = first_line(&self.id);
205        let draft = QuestionDraft {
206            id: if typed_id.trim().is_empty() {
207                self.fallback_id.clone()
208            } else {
209                typed_id
210            },
211            kind: self.kind,
212            question: first_line(&self.question),
213            options: first_line(&self.options),
214            levels: {
215                let typed = first_line(&self.levels);
216                if typed.trim().is_empty() {
217                    Self::DEFAULT_LEVELS.to_string()
218                } else {
219                    typed
220                }
221            },
222        };
223        (draft, self.existing)
224    }
225}
226
227/// A one-line field with the cursor **after** its contents, so typing appends.
228fn line(value: &str) -> TextArea<'static> {
229    let mut area = TextArea::new(vec![value.to_string()]);
230    area.move_cursor(CursorMove::End);
231    area
232}
233
234fn first_line(area: &TextArea<'static>) -> String {
235    area.lines().first().cloned().unwrap_or_default()
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
242
243    fn key(code: KeyCode) -> KeyEvent {
244        KeyEvent::new(code, KeyModifiers::NONE)
245    }
246
247    fn type_str(editor: &mut Editor, text: &str) {
248        for ch in text.chars() {
249            editor.input(key(KeyCode::Char(ch)));
250        }
251    }
252
253    #[test]
254    fn a_noul_offers_no_options_or_levels_field() {
255        let editor = Editor::adding("q1".into());
256
257        assert_eq!(
258            editor.fields(),
259            vec![Field::Id, Field::Kind, Field::Question]
260        );
261    }
262
263    #[test]
264    fn each_kind_offers_the_field_it_uses() {
265        let mut editor = Editor::adding("q1".into());
266
267        editor.cycle_kind(true);
268        assert_eq!(editor.kind(), Kind::Choice);
269        assert!(editor.fields().contains(&Field::Options));
270        assert!(!editor.fields().contains(&Field::Levels));
271
272        editor.cycle_kind(true);
273        assert_eq!(editor.kind(), Kind::Score);
274        assert!(editor.fields().contains(&Field::Levels));
275        assert!(!editor.fields().contains(&Field::Options));
276    }
277
278    #[test]
279    fn tab_walks_the_fields_and_wraps() {
280        let mut editor = Editor::adding("q1".into());
281        assert_eq!(editor.focused(), Field::Id);
282
283        editor.input(key(KeyCode::Tab));
284        assert_eq!(editor.focused(), Field::Kind);
285
286        editor.input(key(KeyCode::Tab));
287        assert_eq!(editor.focused(), Field::Question);
288
289        editor.input(key(KeyCode::Tab));
290        assert_eq!(editor.focused(), Field::Id, "wraps");
291
292        editor.input(key(KeyCode::BackTab));
293        assert_eq!(editor.focused(), Field::Question);
294    }
295
296    /// Switching away from a kind while its own field is focused must not leave focus on a
297    /// field that is no longer shown.
298    #[test]
299    fn changing_kind_moves_focus_off_a_field_that_disappeared() {
300        let mut editor = Editor::adding("q1".into());
301        editor.cycle_kind(true);
302        editor.field = Field::Options;
303
304        editor.cycle_kind(true);
305
306        assert_eq!(editor.kind(), Kind::Score);
307        assert_eq!(editor.focused(), Field::Kind);
308    }
309
310    #[test]
311    fn left_and_right_change_the_kind_only_on_the_kind_field() {
312        let mut editor = Editor::adding("q1".into());
313        editor.field = Field::Kind;
314
315        editor.input(key(KeyCode::Right));
316        assert_eq!(editor.kind(), Kind::Choice);
317
318        editor.input(key(KeyCode::Left));
319        assert_eq!(editor.kind(), Kind::Noul);
320    }
321
322    #[test]
323    fn typing_lands_in_the_focused_field() {
324        let mut editor = Editor::adding("q1".into());
325
326        type_str(&mut editor, "urgent");
327        editor.input(key(KeyCode::Tab));
328        editor.input(key(KeyCode::Tab));
329        type_str(&mut editor, "Is this urgent?");
330
331        let (draft, existing) = editor.finish();
332
333        assert_eq!(draft.id, "urgent");
334        assert_eq!(draft.question, "Is this urgent?");
335        assert_eq!(existing, None);
336    }
337
338    /// Enter is the commit key. The editor must hand it back unconsumed and leave the field
339    /// exactly as it was, rather than splitting it across an invisible second line.
340    #[test]
341    fn enter_is_not_consumed_by_a_field() {
342        let mut editor = Editor::adding("q1".into());
343        type_str(&mut editor, "urgent");
344
345        assert!(
346            !editor.input(key(KeyCode::Enter)),
347            "Enter must reach the caller"
348        );
349
350        let (draft, _) = editor.finish();
351        assert_eq!(draft.id, "urgent");
352        assert!(!draft.id.contains('\n'));
353    }
354
355    /// Found by driving the real binary: the suggested id used to sit in the field with the
356    /// cursor in front of it, so typing "urgent" produced "urgentq1".
357    #[test]
358    fn typing_an_id_replaces_the_suggestion_rather_than_joining_it() {
359        let mut editor = Editor::adding("q1".into());
360
361        type_str(&mut editor, "urgent");
362
363        let (draft, _) = editor.finish();
364        assert_eq!(draft.id, "urgent");
365    }
366
367    /// Found by driving the real binary: the levels field was pre-filled with "5", so typing a
368    /// rubric produced "5unkritisch, gering, mittel, hoch, kritisch".
369    #[test]
370    fn typing_a_rubric_replaces_the_default_rather_than_joining_it() {
371        let mut editor = Editor::adding("q1".into());
372        editor.cycle_kind(true);
373        editor.cycle_kind(true);
374        assert_eq!(editor.kind(), Kind::Score);
375
376        editor.field = Field::Levels;
377        type_str(&mut editor, "gering, mittel, hoch");
378
379        let (draft, _) = editor.finish();
380        assert_eq!(draft.levels, "gering, mittel, hoch");
381    }
382
383    #[test]
384    fn a_blank_rubric_falls_back_to_five_levels() {
385        let mut editor = Editor::adding("q1".into());
386        editor.cycle_kind(true);
387        editor.cycle_kind(true);
388
389        let (draft, _) = editor.finish();
390
391        assert_eq!(draft.levels, "5");
392    }
393
394    /// Someone who does not care about ids should still get a usable one.
395    #[test]
396    fn a_blank_id_falls_back_to_the_suggestion() {
397        let editor = Editor::adding("q3".into());
398
399        let (draft, _) = editor.finish();
400
401        assert_eq!(draft.id, "q3");
402    }
403
404    /// The same cursor rule applies when changing an existing question: typing appends.
405    #[test]
406    fn editing_puts_the_cursor_after_the_existing_text() {
407        let mut editor = Editor::editing(
408            QuestionDraft {
409                id: "team".into(),
410                ..Default::default()
411            },
412            0,
413        );
414
415        type_str(&mut editor, "s");
416
417        let (draft, _) = editor.finish();
418        assert_eq!(draft.id, "teams");
419    }
420
421    /// Clearing the field by hand must still fall back rather than sending an empty id.
422    #[test]
423    fn an_id_cleared_to_nothing_falls_back_too() {
424        let mut editor = Editor::editing(
425            QuestionDraft {
426                id: "team".into(),
427                ..Default::default()
428            },
429            0,
430        );
431        editor.set(Field::Id, "   ");
432
433        let (draft, _) = editor.finish();
434
435        assert_eq!(draft.id, "team");
436    }
437
438    #[test]
439    fn editing_remembers_which_slot_it_came_from() {
440        let editor = Editor::editing(
441            QuestionDraft {
442                id: "team".into(),
443                kind: Kind::Choice,
444                options: "IT, HR".into(),
445                ..Default::default()
446            },
447            3,
448        );
449        assert!(editor.is_editing());
450
451        let (draft, existing) = editor.finish();
452
453        assert_eq!(existing, Some(3));
454        assert_eq!(draft.id, "team");
455        assert_eq!(draft.options, "IT, HR");
456    }
457
458    /// Text typed under one kind is still there after switching away and back.
459    #[test]
460    fn a_kind_change_does_not_discard_the_other_kinds_text() {
461        let mut editor = Editor::adding("q1".into());
462        editor.set(Field::Options, "IT, Facility");
463        editor.set(Field::Levels, "gering, hoch");
464
465        editor.cycle_kind(true);
466        editor.cycle_kind(true);
467        editor.cycle_kind(true);
468
469        let (draft, _) = editor.finish();
470        assert_eq!(draft.kind, Kind::Noul);
471        assert_eq!(draft.options, "IT, Facility");
472        assert_eq!(draft.levels, "gering, hoch");
473    }
474}