Skip to main content

taba_cli/tui/screen/
mod.rs

1//! The profiles screen: the list and, over it, the form. Also a pure state machine;
2//! saving the file and testing the connection are left to whoever drives it (`Services`).
3
4use super::form::{window, Form, FormEvent};
5use super::keys::Key;
6use super::{Frame, Style};
7use crate::profile::{ConfigFile, Profile};
8
9pub const MIN_WIDTH: usize = 60;
10pub const MIN_HEIGHT: usize = 16;
11
12/// What the screen needs from the outside world.
13pub trait Services {
14    /// Writes the whole file.
15    fn save(&mut self, file: &ConfigFile) -> Result<(), String>;
16    /// Tests the connection with a profile that may not be saved yet; returns the result as text.
17    fn check(&mut self, profile: &Profile) -> Result<String, String>;
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Flow {
22    Continue,
23    Quit,
24}
25
26enum Mode {
27    List,
28    /// Waiting for the `y` or `N` of "Remove?".
29    ConfirmRemove(String),
30    Form(Box<Form>),
31}
32
33pub struct App {
34    file: ConfigFile,
35    path: String,
36    session: Option<String>,
37    selected: usize,
38    mode: Mode,
39    status: Option<(bool, String)>,
40}
41
42impl App {
43    /// `session` is the profile `TABA_CLI_PROFILE` chose in this terminal.
44    pub fn new(file: ConfigFile, path: String, session: Option<String>) -> App {
45        App {
46            file,
47            path,
48            session,
49            selected: 0,
50            mode: Mode::List,
51            status: None,
52        }
53    }
54
55    pub fn file(&self) -> &ConfigFile {
56        &self.file
57    }
58
59    fn selected_name(&self) -> Option<String> {
60        self.file
61            .profiles
62            .get(self.selected)
63            .map(|(n, _)| n.clone())
64    }
65
66    pub fn handle(&mut self, key: Key, services: &mut dyn Services) -> Flow {
67        if key == Key::Ctrl('c') {
68            return Flow::Quit;
69        }
70        match std::mem::replace(&mut self.mode, Mode::List) {
71            Mode::List => self.handle_list(key, services),
72            Mode::ConfirmRemove(name) => {
73                if matches!(key, Key::Char('y' | 'Y')) {
74                    self.remove(&name, services);
75                } else {
76                    self.status = Some((false, "Nothing was removed.".to_owned()));
77                }
78                Flow::Continue
79            }
80            Mode::Form(mut form) => {
81                let event = form.handle(key);
82                self.mode = Mode::Form(form);
83                self.handle_form_event(event, services)
84            }
85        }
86    }
87
88    fn handle_list(&mut self, key: Key, services: &mut dyn Services) -> Flow {
89        let count = self.file.profiles.len();
90        match key {
91            Key::Up | Key::Char('k') => self.selected = self.selected.saturating_sub(1),
92            Key::Down | Key::Char('j') => {
93                self.selected = (self.selected + 1).min(count.saturating_sub(1))
94            }
95            Key::Home => self.selected = 0,
96            Key::End => self.selected = count.saturating_sub(1),
97            Key::Enter => {
98                if let Some((name, profile)) = self.file.profiles.get(self.selected) {
99                    let is_default = self.file.default.as_deref() == Some(name.as_str());
100                    let taken = self.file.names().iter().map(|n| n.to_string()).collect();
101                    self.mode = Mode::Form(Box::new(Form::new(
102                        Some((name, profile)),
103                        is_default,
104                        taken,
105                    )));
106                    self.status = None;
107                }
108            }
109            Key::Char('n' | 'N') => {
110                let taken = self.file.names().iter().map(|n| n.to_string()).collect();
111                let first = self.file.profiles.is_empty();
112                self.mode = Mode::Form(Box::new(Form::new(None, first, taken)));
113                self.status = None;
114            }
115            Key::Char('d' | 'D') => {
116                if let Some(name) = self.selected_name() {
117                    let mut next = self.file.clone();
118                    if next.set_default(&name).is_ok() {
119                        self.persist(next, format!("`{name}` is the default profile."), services);
120                    }
121                }
122            }
123            Key::Delete | Key::Char('r' | 'R') => {
124                if let Some(name) = self.selected_name() {
125                    self.mode = Mode::ConfirmRemove(name);
126                    self.status = None;
127                }
128            }
129            Key::Char('q' | 'Q') | Key::Esc | Key::Ctrl('d') => return Flow::Quit,
130            _ => {}
131        }
132        Flow::Continue
133    }
134
135    /// Saves `next`; if it works it becomes the screen's file, if not the previous one stays as it was.
136    fn persist(
137        &mut self,
138        next: ConfigFile,
139        ok_message: String,
140        services: &mut dyn Services,
141    ) -> bool {
142        match services.save(&next) {
143            Ok(()) => {
144                self.file = next;
145                self.selected = self
146                    .selected
147                    .min(self.file.profiles.len().saturating_sub(1));
148                self.status = Some((false, ok_message));
149                true
150            }
151            Err(e) => {
152                self.status = Some((true, e));
153                false
154            }
155        }
156    }
157
158    fn remove(&mut self, name: &str, services: &mut dyn Services) {
159        let mut next = self.file.clone();
160        if next.remove(name).is_ok() {
161            self.persist(next, format!("Profile `{name}` removed."), services);
162        }
163    }
164
165    fn handle_form_event(&mut self, event: FormEvent, services: &mut dyn Services) -> Flow {
166        match event {
167            FormEvent::None => Flow::Continue,
168            FormEvent::Quit => Flow::Quit,
169            FormEvent::Cancel => {
170                self.mode = Mode::List;
171                Flow::Continue
172            }
173            FormEvent::Test => {
174                if let Mode::Form(form) = &mut self.mode {
175                    form.status = Some(if form.is_valid() {
176                        match services.check(&form.profile()) {
177                            Ok(message) => (false, message),
178                            Err(message) => (true, message),
179                        }
180                    } else {
181                        (true, "Fix the fields in red before testing.".to_owned())
182                    });
183                }
184                Flow::Continue
185            }
186            FormEvent::Save => {
187                let Mode::Form(form) = &mut self.mode else {
188                    return Flow::Continue;
189                };
190                if !form.is_valid() {
191                    form.status = Some((true, "Fix the fields in red before saving.".to_owned()));
192                    return Flow::Continue;
193                }
194                let name = form.name().to_owned();
195                let (original, profile, is_default) =
196                    (form.original.clone(), form.profile(), form.is_default);
197                let mut next = self.file.clone();
198                let applied = match &original {
199                    None => next.add(&name, profile),
200                    Some(old) => rename_and_replace(&mut next, old, &name, profile),
201                };
202                if let Err(e) = applied {
203                    if let Mode::Form(form) = &mut self.mode {
204                        form.status = Some((true, e));
205                    }
206                    return Flow::Continue;
207                }
208                if is_default {
209                    next.default = Some(name.clone());
210                } else if next.default.as_deref() == Some(name.as_str()) {
211                    next.default = None;
212                }
213                match services.save(&next) {
214                    Ok(()) => {
215                        self.selected = next
216                            .profiles
217                            .iter()
218                            .position(|(n, _)| *n == name)
219                            .unwrap_or(0);
220                        self.file = next;
221                        self.mode = Mode::List;
222                        self.status = Some((false, format!("Profile `{name}` saved.")));
223                    }
224                    Err(e) => {
225                        if let Mode::Form(form) = &mut self.mode {
226                            form.status = Some((true, e));
227                        }
228                    }
229                }
230                Flow::Continue
231            }
232        }
233    }
234
235    pub fn render(&self, style: Style, width: usize, height: usize) -> Frame {
236        if width < MIN_WIDTH || height < MIN_HEIGHT {
237            return Frame {
238                lines: vec![
239                    style.paint("1;31", "Terminal too small."),
240                    format!("I need at least {MIN_WIDTH} columns by {MIN_HEIGHT} lines (now: {width}x{height})."),
241                    "Enlarge the window. Ctrl+C quits.".to_owned(),
242                ],
243                cursor: None,
244            };
245        }
246        match &self.mode {
247            Mode::Form(form) => form.render(style, width, height),
248            _ => self.render_list(style, width, height),
249        }
250    }
251
252    fn render_list(&self, style: Style, width: usize, height: usize) -> Frame {
253        let mut lines = vec![
254            format!(
255                " {}   {}",
256                style.paint("1", "taba-cli profiles"),
257                style.paint("2", &self.path)
258            ),
259            String::new(),
260        ];
261        if self.file.profiles.is_empty() {
262            lines.push(
263                "   There are no profiles. Without a profile the built-in default applies (http://127.0.0.1:8080)."
264                    .to_owned(),
265            );
266            lines.push("   Press n to create the first one.".to_owned());
267        } else {
268            let name_w = self
269                .file
270                .profiles
271                .iter()
272                .map(|(n, _)| n.chars().count() + session_suffix(&self.session, n).chars().count())
273                .max()
274                .unwrap_or(4)
275                .max(4);
276            let url_w = (width.saturating_sub(name_w + 36)).clamp(12, 40);
277            lines.push(style.paint(
278                "2",
279                &format!(
280                    "   {:<2}{:<name_w$}  {:<url_w$}  {:<12}  default",
281                    "", "name", "url", "token-file"
282                ),
283            ));
284            for (n, (name, profile)) in self.file.profiles.iter().enumerate() {
285                let mark = if self.file.default.as_deref() == Some(name.as_str()) {
286                    "●"
287                } else {
288                    "○"
289                };
290                let label = format!("{name}{}", session_suffix(&self.session, name));
291                let row = format!(
292                    "{:<name_w$}  {:<url_w$}  {:<12}  {mark}",
293                    label,
294                    cut(profile.get("url").unwrap_or("—"), url_w),
295                    cut(profile.get("token-file").unwrap_or("—"), 12),
296                );
297                if n == self.selected {
298                    lines.push(format!(
299                        " {}{}",
300                        style.paint("36", "▶ "),
301                        style.paint("1", &row)
302                    ));
303                } else {
304                    lines.push(format!("   {row}"));
305                }
306            }
307        }
308        lines.push(String::new());
309        if let Mode::ConfirmRemove(name) = &self.mode {
310            lines.push(format!(
311                "   {}",
312                style.paint("33", &format!("Remove \"{name}\"? [y/N]"))
313            ));
314        } else if let Some((is_error, message)) = &self.status {
315            lines.push(format!(
316                "   {}",
317                style.paint(if *is_error { "31" } else { "32" }, message)
318            ));
319        }
320        let footer = " ↑↓ choose   Enter open   n new   d default   Del remove   q quit";
321        window(lines, 0, None, footer, style, width, height)
322    }
323}
324
325fn session_suffix(session: &Option<String>, name: &str) -> &'static str {
326    if session.as_deref() == Some(name) {
327        " (session)"
328    } else {
329        ""
330    }
331}
332
333fn cut(text: &str, max: usize) -> String {
334    if text.chars().count() <= max {
335        text.to_owned()
336    } else {
337        format!(
338            "{}…",
339            text.chars().take(max.saturating_sub(1)).collect::<String>()
340        )
341    }
342}
343
344/// Replaces the profile `old` with `profile` under the name `name`, in the same place in the list, carrying along the
345/// default if it was that one.
346fn rename_and_replace(
347    file: &mut ConfigFile,
348    old: &str,
349    name: &str,
350    profile: Profile,
351) -> Result<(), String> {
352    let at = file
353        .profiles
354        .iter()
355        .position(|(n, _)| n == old)
356        .ok_or_else(|| format!("the profile `{old}` no longer exists"))?;
357    if name != old && file.get(name).is_some() {
358        return Err(format!("a profile `{name}` already exists"));
359    }
360    file.profiles[at] = (name.to_owned(), profile);
361    if file.default.as_deref() == Some(old) {
362        file.default = Some(name.to_owned());
363    }
364    Ok(())
365}
366
367#[cfg(test)]
368mod tests;