tongo/components/input/
input_modal.rs

1use crate::{
2    components::{
3        tab::{CloneWithFocus, TabFocus},
4        Component,
5    },
6    config::Config,
7    system::{
8        command::{Command, CommandCategory, CommandGroup},
9        event::Event,
10        message::{AppAction, Message},
11        signal::SignalQueue,
12    },
13};
14use ratatui::prelude::*;
15use std::{cell::Cell, rc::Rc};
16
17use super::{DefaultFormatter, InnerInput};
18
19const INPUT_MODAL_WIDTH: u16 = 40;
20const INPUT_MODAL_HEIGHT: u16 = 1;
21
22#[derive(Debug, Clone, Copy)]
23pub enum InputKind {
24    NewCollectionName,
25    NewDatabaseName,
26}
27
28impl InputKind {
29    const fn modal_title(self) -> &'static str {
30        match self {
31            Self::NewCollectionName => "New Connection's Name",
32            Self::NewDatabaseName => "New Database's Name",
33        }
34    }
35}
36
37#[derive(Debug, Default, Clone)]
38pub struct InputModal {
39    focus: Rc<Cell<TabFocus>>,
40    kind: Option<InputKind>,
41    input: InnerInput<DefaultFormatter>,
42}
43
44impl CloneWithFocus for InputModal {
45    fn clone_with_focus(&self, focus: Rc<Cell<TabFocus>>) -> Self {
46        Self {
47            focus,
48            ..self.clone()
49        }
50    }
51}
52
53impl InputModal {
54    pub fn new(
55        focus: Rc<Cell<TabFocus>>,
56        cursor_pos: Rc<Cell<(u16, u16)>>,
57        config: Config,
58    ) -> Self {
59        let mut input = InnerInput::new("", cursor_pos, config, DefaultFormatter::default());
60        input.start_editing();
61
62        Self {
63            focus,
64            input,
65            ..Default::default()
66        }
67    }
68
69    pub fn show_with(&mut self, kind: InputKind) {
70        // HACK: it would be better if we could just compute this at render time,
71        // like if the input didn't insist on rendering its own title
72        self.input.set_title(kind.modal_title());
73
74        self.kind = Some(kind);
75        self.focus();
76    }
77}
78
79impl Component for InputModal {
80    fn is_focused(&self) -> bool {
81        self.focus.get() == TabFocus::InputModal
82    }
83
84    fn focus(&self) {
85        self.focus.set(TabFocus::InputModal);
86    }
87
88    fn render(&mut self, frame: &mut Frame, area: Rect) {
89        let layout = Layout::vertical(vec![
90            Constraint::Fill(1),
91            Constraint::Length(INPUT_MODAL_HEIGHT + 2),
92            Constraint::Fill(1),
93        ])
94        .split(area);
95        let layout = Layout::horizontal(vec![
96            Constraint::Fill(1),
97            Constraint::Length(INPUT_MODAL_WIDTH + 2),
98            Constraint::Fill(1),
99        ])
100        .split(layout[1]);
101
102        self.input.render(frame, layout[1], true);
103    }
104
105    fn commands(&self) -> Vec<CommandGroup> {
106        match self.kind {
107            Some(InputKind::NewCollectionName) => vec![
108                CommandGroup::new(vec![Command::Confirm], "create collection")
109                    .in_cat(CommandCategory::StatusBarOnly),
110                CommandGroup::new(vec![Command::Back], "cancel")
111                    .in_cat(CommandCategory::StatusBarOnly),
112            ],
113            Some(InputKind::NewDatabaseName) => vec![
114                CommandGroup::new(vec![Command::Confirm], "create database")
115                    .in_cat(CommandCategory::StatusBarOnly),
116                CommandGroup::new(vec![Command::Back], "cancel")
117                    .in_cat(CommandCategory::StatusBarOnly),
118            ],
119            _ => vec![],
120        }
121    }
122
123    fn handle_raw_event(&mut self, event: &crossterm::event::Event, queue: &mut SignalQueue) {
124        self.input.handle_raw_event(event, queue);
125    }
126
127    fn handle_command(&mut self, command: &Command, queue: &mut SignalQueue) {
128        match command {
129            Command::Confirm => {
130                let value = self.input.value().to_string();
131                self.input.set_value("");
132                queue.push(Event::InputConfirmed(
133                    self.kind.expect("input should not be shown without a kind"),
134                    value,
135                ));
136                queue.push(Message::to_app(AppAction::ExitRawMode));
137            }
138            Command::Back => {
139                self.input.set_value("");
140                queue.push(Event::InputCanceled);
141                queue.push(Message::to_app(AppAction::ExitRawMode));
142            }
143            _ => {}
144        }
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::testing::ComponentTestHarness;
152
153    #[test]
154    fn enter_text_then_confirm() {
155        let mut test = ComponentTestHarness::new(InputModal::default());
156
157        test.component_mut().input.start_editing();
158        test.component_mut().show_with(InputKind::NewCollectionName);
159
160        test.given_string("text!");
161        test.given_command(Command::Confirm);
162
163        test.expect_event(
164            |e| matches!(e, Event::InputConfirmed(InputKind::NewCollectionName, s) if s == "text!"),
165        );
166        test.expect_message(|m| matches!(m.read_as_app(), Some(AppAction::ExitRawMode)));
167        assert_eq!(test.component_mut().input.value(), "");
168    }
169
170    #[test]
171    fn enter_text_then_cancel() {
172        let mut test = ComponentTestHarness::new(InputModal::default());
173
174        test.component_mut().input.start_editing();
175        test.component_mut().show_with(InputKind::NewCollectionName);
176
177        test.given_string("text!");
178        test.given_command(Command::Back);
179
180        test.expect_event(|e| matches!(e, Event::InputCanceled));
181        test.expect_message(|m| matches!(m.read_as_app(), Some(AppAction::ExitRawMode)));
182        assert_eq!(test.component_mut().input.value(), "");
183    }
184}