Skip to main content

taba_cli/tui/form/
mod.rs

1//! A profile's form: text fields, the `output` choice, the "Default profile" box and the
2//! buttons. A pure state machine: receives keys and draws lines, without touching the terminal.
3
4use super::keys::Key;
5use super::{Frame, Style};
6use crate::profile::{self, Profile, DEFAULT_URL, FIELDS, OUTPUTS};
7
8pub const NAME: usize = 0;
9pub const OUTPUT: usize = 6;
10pub const DEFAULT: usize = 10;
11pub const SAVE: usize = 11;
12pub const TEST: usize = 12;
13pub const CANCEL: usize = 13;
14const ITEMS: usize = 14;
15const LABEL_WIDTH: usize = 18;
16
17const LABELS: [&str; 11] = [
18    "Name",
19    "url",
20    "ops-url",
21    "token-file",
22    "token",
23    "ca-file",
24    "output",
25    "connect-timeout",
26    "timeout",
27    "max-attempts",
28    "Default profile",
29];
30
31#[derive(Debug, Clone, Default)]
32struct Text {
33    value: String,
34    /// Cursor position, in characters.
35    cursor: usize,
36}
37
38impl Text {
39    fn from(value: &str) -> Text {
40        Text {
41            value: value.to_owned(),
42            cursor: value.chars().count(),
43        }
44    }
45
46    fn byte_at(&self, chars: usize) -> usize {
47        self.value
48            .char_indices()
49            .nth(chars)
50            .map_or(self.value.len(), |(i, _)| i)
51    }
52
53    fn insert(&mut self, c: char) {
54        let at = self.byte_at(self.cursor);
55        self.value.insert(at, c);
56        self.cursor += 1;
57    }
58
59    fn backspace(&mut self) -> bool {
60        if self.cursor == 0 {
61            return false;
62        }
63        let at = self.byte_at(self.cursor - 1);
64        self.value.remove(at);
65        self.cursor -= 1;
66        true
67    }
68
69    fn delete(&mut self) -> bool {
70        if self.cursor >= self.value.chars().count() {
71            return false;
72        }
73        let at = self.byte_at(self.cursor);
74        self.value.remove(at);
75        true
76    }
77}
78
79/// What the form asks of whoever drives it.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum FormEvent {
82    None,
83    Save,
84    Test,
85    Cancel,
86    Quit,
87}
88
89#[derive(Debug, Clone)]
90pub struct Form {
91    /// The name of the profile being edited; `None` on a new profile.
92    pub original: Option<String>,
93    texts: Vec<Text>,
94    /// 0 = no value (the built-in one), then `json`, `table`, `raw`.
95    output: usize,
96    pub is_default: bool,
97    focus: usize,
98    /// The result line (connection test, save error), with `true` if it is an error.
99    pub status: Option<(bool, String)>,
100    dirty: bool,
101    confirm_discard: bool,
102    taken_names: Vec<String>,
103}
104
105impl Form {
106    /// A new form (`existing` empty) or filled in with a profile. `taken_names` are the
107    /// names that already exist, to refuse a repeated one.
108    pub fn new(
109        existing: Option<(&str, &Profile)>,
110        is_default: bool,
111        taken_names: Vec<String>,
112    ) -> Form {
113        let mut texts = vec![Text::default(); 10];
114        let mut output = 0;
115        if let Some((name, profile)) = existing {
116            texts[NAME] = Text::from(name);
117            for (n, field) in FIELDS.iter().enumerate() {
118                if let Some(value) = profile.get(field) {
119                    if *field == "output" {
120                        output = OUTPUTS
121                            .iter()
122                            .position(|o| *o == value)
123                            .map_or(0, |p| p + 1);
124                    } else {
125                        texts[n + 1] = Text::from(value);
126                    }
127                }
128            }
129        }
130        Form {
131            original: existing.map(|(n, _)| n.to_owned()),
132            texts,
133            output,
134            is_default,
135            focus: NAME,
136            status: None,
137            dirty: false,
138            confirm_discard: false,
139            taken_names,
140        }
141    }
142
143    pub fn name(&self) -> &str {
144        self.texts[NAME].value.trim()
145    }
146
147    pub fn dirty(&self) -> bool {
148        self.dirty
149    }
150
151    fn value(&self, item: usize) -> String {
152        if item == OUTPUT {
153            return match self.output {
154                0 => String::new(),
155                n => OUTPUTS[n - 1].to_owned(),
156            };
157        }
158        self.texts[item].value.trim().to_owned()
159    }
160
161    /// The profile with the fields filled in (empty ones are left out). Ignores invalid fields.
162    pub fn profile(&self) -> Profile {
163        let mut profile = Profile::default();
164        for (n, field) in FIELDS.iter().enumerate() {
165            let value = self.value(n + 1);
166            if !value.is_empty() {
167                let _ = profile.set(field, &value);
168            }
169        }
170        profile
171    }
172
173    /// The error message of each item (`None` = valid).
174    pub fn errors(&self) -> Vec<Option<String>> {
175        let mut errors = vec![None; ITEMS];
176        let name = self.name();
177        errors[NAME] = if name.is_empty() {
178            Some("the name is required".to_owned())
179        } else if let Err(e) = profile::validate_name(name) {
180            Some(e)
181        } else if self.original.as_deref() != Some(name)
182            && self.taken_names.iter().any(|n| n == name)
183        {
184            Some(format!("a profile `{name}` already exists"))
185        } else {
186            None
187        };
188        for (n, field) in FIELDS.iter().enumerate() {
189            let value = self.value(n + 1);
190            if !value.is_empty() {
191                errors[n + 1] = profile::validate_field(field, &value).err();
192            }
193        }
194        errors
195    }
196
197    pub fn is_valid(&self) -> bool {
198        self.errors().iter().all(Option::is_none)
199    }
200
201    fn touched(&mut self) {
202        self.dirty = true;
203        self.status = None;
204    }
205
206    fn move_focus(&mut self, delta: isize) {
207        self.focus = (self.focus as isize + delta).rem_euclid(ITEMS as isize) as usize;
208    }
209
210    pub fn handle(&mut self, key: Key) -> FormEvent {
211        if self.confirm_discard {
212            self.confirm_discard = false;
213            self.status = None;
214            if key == Key::Esc {
215                return FormEvent::Cancel;
216            }
217        }
218        match key {
219            Key::Ctrl('c') => return FormEvent::Quit,
220            Key::Ctrl('s') => return FormEvent::Save,
221            Key::Esc => {
222                if self.dirty {
223                    self.confirm_discard = true;
224                    self.status = Some((
225                        true,
226                        "There are unsaved changes. Esc again discards; any other key goes back."
227                            .to_owned(),
228                    ));
229                    return FormEvent::None;
230                }
231                return FormEvent::Cancel;
232            }
233            Key::Up | Key::BackTab => self.move_focus(-1),
234            Key::Down | Key::Tab => self.move_focus(1),
235            _ => {}
236        }
237        match self.focus {
238            SAVE | TEST | CANCEL => match key {
239                Key::Enter | Key::Char(' ') => {
240                    return match self.focus {
241                        SAVE => FormEvent::Save,
242                        TEST => FormEvent::Test,
243                        _ => self.cancel_or_confirm(),
244                    };
245                }
246                Key::Left => self.move_focus(-1),
247                Key::Right => self.focus = (self.focus + 1).min(CANCEL),
248                _ => {}
249            },
250            DEFAULT => match key {
251                Key::Char(' ') | Key::Left | Key::Right => {
252                    self.is_default = !self.is_default;
253                    self.touched();
254                }
255                Key::Enter => self.move_focus(1),
256                _ => {}
257            },
258            OUTPUT => match key {
259                Key::Left => {
260                    self.output = (self.output + OUTPUTS.len()) % (OUTPUTS.len() + 1);
261                    self.touched();
262                }
263                Key::Right | Key::Char(' ') => {
264                    self.output = (self.output + 1) % (OUTPUTS.len() + 1);
265                    self.touched();
266                }
267                Key::Enter => self.move_focus(1),
268                _ => {}
269            },
270            item => {
271                let text = &mut self.texts[item];
272                let changed = match key {
273                    Key::Char(c) => {
274                        text.insert(c);
275                        true
276                    }
277                    Key::Backspace => text.backspace(),
278                    Key::Delete => text.delete(),
279                    Key::Left => {
280                        text.cursor = text.cursor.saturating_sub(1);
281                        false
282                    }
283                    Key::Right => {
284                        text.cursor = (text.cursor + 1).min(text.value.chars().count());
285                        false
286                    }
287                    Key::Home => {
288                        text.cursor = 0;
289                        false
290                    }
291                    Key::End => {
292                        text.cursor = text.value.chars().count();
293                        false
294                    }
295                    Key::Ctrl('u') => {
296                        let had = !text.value.is_empty();
297                        *text = Text::default();
298                        had
299                    }
300                    Key::Enter => {
301                        self.move_focus(1);
302                        false
303                    }
304                    _ => false,
305                };
306                if changed {
307                    self.touched();
308                }
309            }
310        }
311        FormEvent::None
312    }
313
314    fn cancel_or_confirm(&mut self) -> FormEvent {
315        if self.dirty {
316            self.confirm_discard = true;
317            self.status = Some((
318                true,
319                "There are unsaved changes. Esc again discards; any other key goes back."
320                    .to_owned(),
321            ));
322            FormEvent::None
323        } else {
324            FormEvent::Cancel
325        }
326    }
327
328    /// The value that will apply when the field is empty (or `None` if there is none).
329    fn placeholder(&self, item: usize) -> Option<String> {
330        match item {
331            1 => Some(DEFAULT_URL.to_owned()),
332            2 => {
333                let url = self.value(1);
334                Some(if url.is_empty() {
335                    DEFAULT_URL.to_owned()
336                } else {
337                    url
338                })
339            }
340            3 | 4 => Some("none".to_owned()),
341            5 => Some("system roots".to_owned()),
342            6 => Some("json".to_owned()),
343            7 => Some("5".to_owned()),
344            8 => Some("30".to_owned()),
345            9 => Some("3".to_owned()),
346            _ => None,
347        }
348    }
349
350    pub fn render(&self, style: Style, width: usize, height: usize) -> Frame {
351        let errors = self.errors();
352        let title = match &self.original {
353            Some(name) => format!(" Profile  {name}"),
354            None => " New profile".to_owned(),
355        };
356        let mut lines = vec![style.paint("1", &title), String::new()];
357        let mut focus_line = 0;
358        let mut cursor = None;
359        for item in 0..=DEFAULT {
360            let focused = self.focus == item;
361            let marker = if focused {
362                style.paint("36", " ▸ ")
363            } else {
364                "   ".to_owned()
365            };
366            let label = format!("{:<LABEL_WIDTH$}", LABELS[item]);
367            let label = if focused {
368                style.paint("1;36", &label)
369            } else {
370                label
371            };
372            let shown = match item {
373                OUTPUT => {
374                    let current = match self.output {
375                        0 => style.paint("2", &format!("({})", OUTPUTS[0])),
376                        n => OUTPUTS[n - 1].to_owned(),
377                    };
378                    format!("‹ {current} ›")
379                }
380                DEFAULT => format!("[{}]", if self.is_default { "x" } else { " " }),
381                _ => {
382                    let text = &self.texts[item];
383                    if text.value.is_empty() {
384                        match self.placeholder(item) {
385                            Some(p) => style.paint("2", &format!("({p})")),
386                            None => String::new(),
387                        }
388                    } else {
389                        text.value.clone()
390                    }
391                }
392            };
393            if focused {
394                focus_line = lines.len();
395                if item != OUTPUT && item != DEFAULT {
396                    cursor = Some((lines.len(), 3 + LABEL_WIDTH + self.texts[item].cursor));
397                }
398            }
399            lines.push(format!("{marker}{label}{shown}"));
400            if let Some(message) = &errors[item] {
401                lines.push(format!(
402                    "{}{}",
403                    " ".repeat(3 + LABEL_WIDTH),
404                    style.paint("31", message)
405                ));
406            }
407        }
408        lines.push(String::new());
409        let button = |item: usize, text: &str| {
410            if self.focus == item {
411                if style.color {
412                    style.paint("7", &format!("[ {text} ]"))
413                } else {
414                    format!("[>{text}<]")
415                }
416            } else {
417                format!("[ {text} ]")
418            }
419        };
420        if matches!(self.focus, SAVE | TEST | CANCEL) {
421            focus_line = lines.len();
422        }
423        lines.push(format!(
424            "   {}  {}  {}",
425            button(SAVE, "Save"),
426            button(TEST, "Test connection"),
427            button(CANCEL, "Cancel")
428        ));
429        if let Some((is_error, message)) = &self.status {
430            lines.push(String::new());
431            lines.push(format!(
432                "   {}",
433                style.paint(if *is_error { "31" } else { "32" }, message)
434            ));
435        }
436        let footer = " ↑↓/Tab field   ←→ option   Space toggle   Ctrl+S save   Esc cancel";
437        window(lines, focus_line, cursor, footer, style, width, height)
438    }
439}
440
441/// Trims the lines to fit the height, keeping the focused line in view, and adds the footer.
442pub fn window(
443    lines: Vec<String>,
444    focus_line: usize,
445    cursor: Option<(usize, usize)>,
446    footer: &str,
447    style: Style,
448    _width: usize,
449    height: usize,
450) -> Frame {
451    let body = height.saturating_sub(2).max(1);
452    let start = if lines.len() <= body {
453        0
454    } else {
455        focus_line.saturating_sub(body - 1).min(lines.len() - body)
456    };
457    let mut shown: Vec<String> = lines.into_iter().skip(start).take(body).collect();
458    while shown.len() < body {
459        shown.push(String::new());
460    }
461    shown.push(String::new());
462    shown.push(style.paint("2", footer));
463    Frame {
464        cursor: cursor.and_then(|(line, col)| {
465            line.checked_sub(start)
466                .filter(|l| *l < body)
467                .map(|l| (l, col))
468        }),
469        lines: shown,
470    }
471}
472
473#[cfg(test)]
474mod tests;