tui_dialog/
lib.rs

1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3#![deny(missing_docs)]
4
5use std::mem;
6
7use crossterm::event::KeyCode;
8use ratatui::widgets::{Block, Borders, Clear, Paragraph};
9use ratatui_core::{
10    buffer::Buffer,
11    layout::{Alignment, Constraint, Layout, Rect},
12    style::{Color, Style, Stylize},
13    text::{Line, Span},
14    widgets::Widget,
15};
16
17/// The default title at the bottom of the widget.
18pub const BOTTOM_TITLE: &str = "Press Enter to submit or Esc to abort";
19
20/// The data structure for the dialog.
21#[derive(PartialEq, Default, Clone)]
22pub struct Dialog {
23    /// Whether or not the dialog box is open.
24    pub open: bool,
25    /// Whether or not input has been submitted.
26    pub submitted: bool,
27    /// The text being written into the dialog box when it's open. This field can be used to
28    /// pre-populate the dialog with a value before it is opened. It will be cleared when the
29    /// user presses `Esc` or `Enter`.
30    pub working_input: String,
31    /// The text that has been written and is submitted for use when the user presses `Enter`. Any
32    /// surrounding whitespace will be trimmed.
33    pub submitted_input: String,
34    cursor_position: usize,
35    title_top: Option<String>,
36    title_bottom: Option<String>,
37    borders: Option<Borders>,
38    style: Option<Style>,
39}
40
41impl Dialog {
42    /// Respond to key press.
43    pub fn key_action(&mut self, key_code: &KeyCode) {
44        self.submitted = false;
45        match key_code {
46            KeyCode::Char(to_insert) => self.insert_char(*to_insert),
47            KeyCode::Backspace => self.backspace(),
48            KeyCode::Delete => self.delete(),
49            KeyCode::End => self.end(),
50            KeyCode::Home => self.home(),
51            KeyCode::Left => self.move_cursor_left(),
52            KeyCode::Right => self.move_cursor_right(),
53            KeyCode::Enter => {
54                // Take the existing working_input and replace any previously submitted input.
55                // Working input is thus set to default (empty string).
56                self.submitted_input = mem::take(&mut self.working_input);
57                self.submitted_input = self.submitted_input.trim().to_string();
58
59                // Mark input as being submitted and close the dialog box, resetting cursor
60                // position for next use.
61                self.submitted = true;
62                self.open = false;
63                self.cursor_position = 0;
64            }
65            KeyCode::Esc => {
66                self.working_input.clear();
67                self.open = false;
68                self.cursor_position = 0;
69            }
70            _ => (),
71        }
72    }
73
74    /// Set the top title of the block surrounding the widget.
75    ///
76    /// If the method is not used, there will be no top title.
77    pub fn title_top(&mut self, title: &str) -> Self {
78        self.title_top = Some(title.to_owned());
79        self.clone()
80    }
81
82    /// Set the bottom title of the block surrounding the widget.
83    ///
84    /// If the method is not used, the bottom title will default to [`BOTTOM_TITLE`].
85    pub fn title_bottom(&mut self, title: &str) -> Self {
86        self.title_bottom = Some(title.to_owned());
87        self.clone()
88    }
89
90    /// Set borders of the block surrounding the widget.
91    ///
92    /// If the method is not used, the borders will default to [`ratatui::widgets::Borders::ALL`].
93    pub fn borders(&mut self, borders: Borders) -> Self {
94        self.borders = Some(borders);
95        self.clone()
96    }
97
98    /// Set the style of the widget.
99    ///
100    /// If the method is not used, the style will be the default with a
101    /// [`ratatui::style::Color::DarkGray`] background.
102    pub fn style(&mut self, style: Style) -> Self {
103        self.style = Some(style);
104        self.clone()
105    }
106
107    /// Render working input of the dialog, showing cursor position.
108    fn render_working_input(&self) -> Line<'_> {
109        // Get working input, adding a space after it (to show cursor position).
110        let text = format!("{} ", self.working_input);
111        let text_len = text.chars().count();
112        let text = text.chars();
113
114        // Split the text up into before cursor, under cursor, and after cursor.
115        let before_cursor = Span::raw(text.clone().take(self.cursor_position).collect::<String>());
116
117        let under_cursor = Span::raw(
118            text.clone()
119                .skip(self.cursor_position)
120                .take(1)
121                .collect::<String>(),
122        )
123        .reversed();
124
125        let after_cursor = if self.cursor_position != text_len {
126            Span::raw(
127                text.clone()
128                    .skip(self.cursor_position + 1)
129                    .collect::<String>(),
130            )
131        } else {
132            Span::raw("")
133        };
134
135        Line::from(vec![before_cursor, under_cursor, after_cursor])
136    }
137
138    fn move_cursor_left(&mut self) {
139        let cursor_moved_left = self.cursor_position.saturating_sub(1);
140        self.cursor_position = self.clamp_cursor(cursor_moved_left);
141    }
142
143    fn move_cursor_right(&mut self) {
144        let cursor_moved_right = self.cursor_position.saturating_add(1);
145        self.cursor_position = self.clamp_cursor(cursor_moved_right);
146    }
147
148    fn insert_char(&mut self, new_char: char) {
149        self.working_input.insert(self.cursor_position, new_char);
150        self.move_cursor_right();
151    }
152
153    fn backspace(&mut self) {
154        if self.cursor_position != 0 {
155            let current_index = self.cursor_position;
156            let from_left_to_current_index = current_index - 1;
157
158            // Get all characters before the selected character.
159            let before_char_to_delete = self.working_input.chars().take(from_left_to_current_index);
160
161            // Get all characters after selected character.
162            let after_char_to_delete = self.working_input.chars().skip(current_index);
163
164            // Put all characters together except the selected one, thus removing it.
165            self.working_input = before_char_to_delete.chain(after_char_to_delete).collect();
166            self.move_cursor_left();
167        }
168    }
169
170    fn delete(&mut self) {
171        let current_index = self.cursor_position;
172
173        // Get all characters before the selected character.
174        let before_char_to_delete = self.working_input.chars().take(current_index);
175
176        // Get all characters after selected character.
177        let after_char_to_delete = self.working_input.chars().skip(current_index + 1);
178
179        // Put all characters together except the selected one, thus removing it.
180        self.working_input = before_char_to_delete.chain(after_char_to_delete).collect();
181    }
182
183    fn end(&mut self) {
184        self.cursor_position = self.working_input.chars().count();
185    }
186
187    fn home(&mut self) {
188        self.cursor_position = 0;
189    }
190
191    fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
192        new_cursor_pos.clamp(0, self.working_input.len())
193    }
194}
195
196impl Widget for Dialog {
197    fn render(mut self, area: Rect, buf: &mut Buffer) {
198        if self.open {
199            Clear.render(area, buf);
200
201            let mut dialog_block = Block::default().title_alignment(Alignment::Center);
202
203            if let Some(ref mut v) = self.title_top {
204                dialog_block = dialog_block.title_top(mem::take(v))
205            }
206
207            dialog_block = if let Some(ref mut v) = self.title_bottom {
208                dialog_block.title_bottom(mem::take(v))
209            } else {
210                dialog_block.title_bottom(BOTTOM_TITLE)
211            };
212
213            dialog_block = if let Some(ref mut v) = self.borders {
214                dialog_block.borders(mem::take(v))
215            } else {
216                dialog_block.borders(Borders::ALL)
217            };
218
219            dialog_block = if let Some(ref mut v) = self.style {
220                dialog_block.style(mem::take(v))
221            } else {
222                dialog_block.style(Style::default().bg(Color::DarkGray))
223            };
224
225            Paragraph::new(self.render_working_input())
226                .block(dialog_block)
227                .render(area, buf)
228        }
229    }
230}
231
232/// Create a centered [`Rect`] to place the dialog in.
233///
234/// `frame.area()` will typically be used for the r (Rect) parameter.
235///
236/// To offset horizontally or vertically, pass in negative values to go left or
237/// up, and positive values to go right or down. Use 0 for these parameters for no offset.
238///
239/// Based on <https://ratatui.rs/how-to/layout/center-a-rect/>.
240pub fn centered_rect(
241    r: Rect,
242    width: u16,
243    height: u16,
244    horizontal_offset: i16,
245    vertical_offset: i16,
246) -> Rect {
247    // Make the vertical layout first. `index` is the part of the layout the corresponds to the
248    // rect we're building.
249    let (dialog_layout, index) = if vertical_offset.is_negative() {
250        (
251            Layout::vertical([
252                Constraint::Fill(1),
253                Constraint::Length(height),
254                Constraint::Fill(1),
255                Constraint::Length(vertical_offset.unsigned_abs()),
256            ])
257            .split(r),
258            1,
259        )
260    } else {
261        (
262            Layout::vertical([
263                Constraint::Length(vertical_offset as u16),
264                Constraint::Fill(1),
265                Constraint::Length(height),
266                Constraint::Fill(1),
267            ])
268            .split(r),
269            2,
270        )
271    };
272
273    // Now use that to do horizontal.
274    if horizontal_offset.is_negative() {
275        Layout::horizontal([
276            Constraint::Fill(1),
277            Constraint::Length(width),
278            Constraint::Fill(1),
279            Constraint::Length(horizontal_offset.unsigned_abs()),
280        ])
281        .split(dialog_layout[index])[1]
282    } else {
283        Layout::horizontal([
284            Constraint::Length(horizontal_offset as u16),
285            Constraint::Fill(1),
286            Constraint::Length(width),
287            Constraint::Fill(1),
288        ])
289        .split(dialog_layout[index])[2]
290    }
291}