Skip to main content

taba_cli/tui/editor/
mod.rs

1//! A small form of text fields and checkboxes (doc 34, sections 6.5 and 6.7): creating and editing a token, a
2//! profile. It is plain state: the keys change it and say what the person asked for (`Outcome`); the screens decide
3//! what that means, and the popup draws it.
4
5use ratatui::crossterm::event::{KeyCode, KeyEvent};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Kind {
9    Text,
10    /// A text that is shown as dots (a token kept in a profile).
11    Secret,
12    Check,
13    /// Several lines, with a cursor that moves (the body of a message).
14    Area,
15}
16
17/// How many lines an area shows at once.
18pub const AREA_ROWS: usize = 7;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Field {
22    /// What the code that reads the form calls it.
23    pub id: &'static str,
24    /// The catalog key of its label.
25    pub label: &'static str,
26    pub kind: Kind,
27    pub value: String,
28    pub checked: bool,
29    /// The catalog key of the reason the field cannot be changed, when it cannot.
30    pub locked: Option<&'static str>,
31    /// Where the cursor is, in characters from the start; only an area uses it.
32    pub cursor: usize,
33}
34
35impl Field {
36    pub fn text(id: &'static str, label: &'static str, value: &str) -> Field {
37        Field {
38            id,
39            label,
40            kind: Kind::Text,
41            value: value.to_owned(),
42            checked: false,
43            locked: None,
44            cursor: 0,
45        }
46    }
47
48    /// A text of several lines, with the cursor at its end.
49    pub fn area(id: &'static str, label: &'static str, value: &str) -> Field {
50        Field {
51            kind: Kind::Area,
52            cursor: value.chars().count(),
53            ..Field::text(id, label, value)
54        }
55    }
56
57    /// The cursor as a line and a column (both from 0, in characters).
58    pub fn line_col(&self) -> (usize, usize) {
59        let before: String = self.value.chars().take(self.cursor).collect();
60        let line = before.matches('\n').count();
61        let col = before.rsplit('\n').next().map_or(0, |l| l.chars().count());
62        (line, col)
63    }
64
65    fn lines(&self) -> Vec<&str> {
66        self.value.split('\n').collect()
67    }
68
69    /// Puts the cursor on a line and a column, clipped to what exists.
70    fn set_line_col(&mut self, line: usize, col: usize) {
71        let lines = self.lines();
72        let line = line.min(lines.len() - 1);
73        let before: usize = lines.iter().take(line).map(|l| l.chars().count() + 1).sum();
74        self.cursor = before + col.min(lines[line].chars().count());
75    }
76
77    fn byte_at(&self, chars: usize) -> usize {
78        self.value
79            .char_indices()
80            .nth(chars)
81            .map_or(self.value.len(), |(at, _)| at)
82    }
83
84    fn insert_at_cursor(&mut self, text: &str) {
85        let at = self.byte_at(self.cursor);
86        self.value.insert_str(at, text);
87        self.cursor += text.chars().count();
88    }
89
90    pub fn secret(id: &'static str, label: &'static str, value: &str) -> Field {
91        Field {
92            kind: Kind::Secret,
93            ..Field::text(id, label, value)
94        }
95    }
96
97    pub fn check(id: &'static str, label: &'static str, checked: bool) -> Field {
98        Field {
99            kind: Kind::Check,
100            checked,
101            ..Field::text(id, label, "")
102        }
103    }
104
105    pub fn locked(mut self, reason: &'static str) -> Field {
106        self.locked = Some(reason);
107        self
108    }
109}
110
111/// What the form is for, with what the code needs to carry it out.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum Purpose {
114    /// `clone_of` is the id and the name of the token being cloned.
115    TokenCreate {
116        clone_of: Option<(String, String)>,
117    },
118    /// The id, and the name and expiry the form started with (only what changed is sent).
119    TokenEdit {
120        id: String,
121        name: String,
122        expires: String,
123    },
124    TopicCreate,
125    /// The topic, and the text each editable field started with (only what changed is sent).
126    TopicEdit {
127        name: String,
128        was: Vec<(&'static str, String)>,
129    },
130    ProfileNew,
131    ProfileEdit {
132        original: String,
133    },
134    /// A message to publish to the topic.
135    MessagePublish {
136        topic: String,
137    },
138    /// Reading messages of a topic: how many, and how long they stay invisible to the other consumers.
139    MessageReceive {
140        topic: String,
141    },
142    /// A held message: for how long it stays invisible from now on.
143    MessageVisibility {
144        topic: String,
145        id: String,
146        receipt: String,
147    },
148}
149
150impl Purpose {
151    /// The catalog key of the popup's title.
152    pub fn title(&self) -> &'static str {
153        match self {
154            Purpose::TokenCreate { clone_of: None } => "editor.token-create",
155            Purpose::TokenCreate { .. } => "editor.token-clone",
156            Purpose::TokenEdit { .. } => "editor.token-edit",
157            Purpose::TopicCreate => "editor.topic-create",
158            Purpose::TopicEdit { .. } => "editor.topic-edit",
159            Purpose::ProfileNew => "editor.profile-new",
160            Purpose::ProfileEdit { .. } => "editor.profile-edit",
161            Purpose::MessagePublish { .. } => "editor.message-publish",
162            Purpose::MessageReceive { .. } => "editor.message-receive",
163            Purpose::MessageVisibility { .. } => "editor.message-visibility",
164        }
165    }
166
167    /// The catalog key of the line that explains the fields.
168    pub fn help(&self) -> &'static str {
169        if self.is_profile() {
170            "editor.help-profile"
171        } else if matches!(self, Purpose::MessageReceive { .. }) {
172            "editor.help-receive"
173        } else if matches!(self, Purpose::MessageVisibility { .. }) {
174            "editor.help-visibility"
175        } else if self.is_message() {
176            "editor.help-publish"
177        } else if self.is_topic() {
178            "editor.help-topic"
179        } else {
180            "editor.help-token"
181        }
182    }
183
184    pub fn is_topic(&self) -> bool {
185        matches!(self, Purpose::TopicCreate | Purpose::TopicEdit { .. })
186    }
187
188    pub fn is_message(&self) -> bool {
189        matches!(
190            self,
191            Purpose::MessagePublish { .. }
192                | Purpose::MessageReceive { .. }
193                | Purpose::MessageVisibility { .. }
194        )
195    }
196
197    pub fn is_profile(&self) -> bool {
198        matches!(self, Purpose::ProfileNew | Purpose::ProfileEdit { .. })
199    }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct Editor {
204    pub purpose: Purpose,
205    pub fields: Vec<Field>,
206    pub focus: usize,
207    /// Why the last attempt to save did not work.
208    pub error: Option<String>,
209    /// The result of a test of the connection: `true` for a failure.
210    pub note: Option<(bool, String)>,
211    /// A call is running: the keys wait.
212    pub busy: bool,
213}
214
215/// What a key asked of the form.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum Outcome {
218    None,
219    Cancel,
220    Submit,
221    Test,
222}
223
224impl Editor {
225    pub fn new(purpose: Purpose, fields: Vec<Field>) -> Editor {
226        let mut editor = Editor {
227            purpose,
228            fields,
229            focus: 0,
230            error: None,
231            note: None,
232            busy: false,
233        };
234        if editor.fields.first().is_some_and(|f| f.locked.is_some()) {
235            editor.focus = editor.step(0, 1);
236        }
237        editor
238    }
239
240    pub fn field(&self, id: &str) -> Option<&Field> {
241        self.fields.iter().find(|f| f.id == id)
242    }
243
244    pub fn text(&self, id: &str) -> &str {
245        self.field(id).map_or("", |f| f.value.trim())
246    }
247
248    pub fn checked(&self, id: &str) -> bool {
249        self.field(id).is_some_and(|f| f.checked)
250    }
251
252    /// Puts the focus on a field by its id (to point at what is wrong).
253    pub fn focus_on(&mut self, id: &str) {
254        if let Some(at) = self.fields.iter().position(|f| f.id == id) {
255            self.focus = at;
256        }
257    }
258
259    /// The next field that can be edited from `from`, going `delta` (1 or -1) and stopping at the ends.
260    fn step(&self, from: usize, delta: i32) -> usize {
261        let mut at = from;
262        loop {
263            let next = i64::try_from(at).unwrap_or(0) + i64::from(delta);
264            if next < 0 || next >= i64::try_from(self.fields.len()).unwrap_or(0) {
265                return from;
266            }
267            at = next as usize;
268            if self.fields[at].locked.is_none() {
269                return at;
270            }
271        }
272    }
273
274    fn is_last(&self) -> bool {
275        self.step(self.focus, 1) == self.focus
276    }
277
278    /// Adds typed or pasted text to the focused field. A line field drops the line breaks; an area keeps them.
279    pub fn insert(&mut self, text: &str) {
280        if let Some(field) = self.fields.get_mut(self.focus) {
281            if field.locked.is_some() || field.kind == Kind::Check {
282                return;
283            }
284            if field.kind == Kind::Area {
285                let text = text.replace("\r\n", "\n").replace('\r', "\n");
286                let clean: String = text
287                    .chars()
288                    .map(|c| if c == '\t' { ' ' } else { c })
289                    .filter(|c| *c == '\n' || !c.is_control())
290                    .collect();
291                field.insert_at_cursor(&clean);
292            } else {
293                field.value.extend(text.chars().filter(|c| !c.is_control()));
294            }
295            self.error = None;
296        }
297    }
298
299    /// The keys of the area that has the focus; `None` when the key is not for it.
300    fn area_key(&mut self, event: &KeyEvent) -> Option<()> {
301        let ctrl = crate::tui::keymap::ctrl_of(event);
302        let at = self.focus;
303        let field = self.fields.get_mut(at)?;
304        if field.kind != Kind::Area || field.locked.is_some() {
305            return None;
306        }
307        let (line, col) = field.line_col();
308        let last = field.lines().len() - 1;
309        match event.code {
310            KeyCode::Enter => field.insert_at_cursor("\n"),
311            KeyCode::Left => field.cursor = field.cursor.saturating_sub(1),
312            KeyCode::Right => field.cursor = (field.cursor + 1).min(field.value.chars().count()),
313            KeyCode::Home => field.set_line_col(line, 0),
314            KeyCode::End => field.set_line_col(line, usize::MAX),
315            KeyCode::Up if line > 0 => field.set_line_col(line - 1, col),
316            KeyCode::Down if line < last => field.set_line_col(line + 1, col),
317            // At the first or the last line the arrows leave the area, as in the other fields.
318            KeyCode::Up | KeyCode::Down => return None,
319            KeyCode::Backspace if field.cursor > 0 => {
320                let to = field.byte_at(field.cursor);
321                let from = field.byte_at(field.cursor - 1);
322                field.value.replace_range(from..to, "");
323                field.cursor -= 1;
324            }
325            KeyCode::Delete if field.cursor < field.value.chars().count() => {
326                let from = field.byte_at(field.cursor);
327                let to = field.byte_at(field.cursor + 1);
328                field.value.replace_range(from..to, "");
329            }
330            KeyCode::Backspace | KeyCode::Delete => {}
331            KeyCode::Char('u') if ctrl => {
332                field.value.clear();
333                field.cursor = 0;
334            }
335            KeyCode::Char(c) if !ctrl => {
336                let mut buffer = [0u8; 4];
337                field.insert_at_cursor(c.encode_utf8(&mut buffer));
338            }
339            _ => return None,
340        }
341        self.error = None;
342        Some(())
343    }
344
345    pub fn handle(&mut self, event: &KeyEvent) -> Outcome {
346        if self.busy {
347            return Outcome::None;
348        }
349        let ctrl = crate::tui::keymap::ctrl_of(event);
350        let sends = matches!(event.code, KeyCode::Char('s' | 'S')) && ctrl;
351        if !sends && event.code != KeyCode::Esc && self.area_key(event).is_some() {
352            return Outcome::None;
353        }
354        match event.code {
355            KeyCode::Esc => return Outcome::Cancel,
356            KeyCode::Char('s' | 'S') if ctrl => return Outcome::Submit,
357            KeyCode::Char('t' | 'T') if ctrl && self.purpose.is_profile() => return Outcome::Test,
358            KeyCode::Tab | KeyCode::Down => self.focus = self.step(self.focus, 1),
359            KeyCode::BackTab | KeyCode::Up => self.focus = self.step(self.focus, -1),
360            KeyCode::Enter => {
361                if self.is_last() {
362                    return Outcome::Submit;
363                }
364                self.focus = self.step(self.focus, 1);
365            }
366            KeyCode::Backspace => {
367                if let Some(field) = self.fields.get_mut(self.focus) {
368                    if field.locked.is_none() && field.kind != Kind::Check {
369                        field.value.pop();
370                        self.error = None;
371                    }
372                }
373            }
374            KeyCode::Char('u') if ctrl => {
375                if let Some(field) = self.fields.get_mut(self.focus) {
376                    if field.locked.is_none() && field.kind != Kind::Check {
377                        field.value.clear();
378                    }
379                }
380            }
381            KeyCode::Char(' ')
382                if self
383                    .fields
384                    .get(self.focus)
385                    .is_some_and(|f| f.kind == Kind::Check && f.locked.is_none()) =>
386            {
387                let field = &mut self.fields[self.focus];
388                field.checked = !field.checked;
389                self.error = None;
390            }
391            KeyCode::Char(c) if !ctrl => {
392                let mut buffer = [0u8; 4];
393                self.insert(c.encode_utf8(&mut buffer));
394            }
395            _ => {}
396        }
397        Outcome::None
398    }
399}
400
401#[cfg(test)]
402mod tests;