1use 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 existing: Option<usize>,
36 field: Field,
37 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 pub const DEFAULT_LEVELS: &'static str = "5";
49
50 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 pub fn fallback_id(&self) -> &str {
89 &self.fallback_id
90 }
91
92 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 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, }
131 }
132
133 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 if !self.fields().contains(&self.field) {
166 self.field = Field::Kind;
167 }
168 }
169
170 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 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 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
227fn 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}