tui_dialog/
lib.rs

1#![forbid(unsafe_code)]
2#![deny(missing_docs)]
3
4//! A widget for entering a single line of text in a dialog for [Ratatui](https://ratatui.rs).
5//! It also includes a function for creating a centered `Rect` to place the dialog in.
6//!
7//! Based on <https://github.com/ratatui/ratatui/blob/v0.26.1/examples/user_input.rs>.
8//!
9//! NOTE: The widget only works with the <a href="https://docs.rs/crossterm">crossterm</a> backend. Adding others is not a priority at this time.
10//!
11//! # Examples
12//!
13//! For a full (but minimal) app demonstrating use, clone <a href="https://codeberg.org/kdwarn/tui-dialog">the repository</a> and run `cargo run --example basic`.
14//!
15//! You can also check out my app <a href="https://crates.io/crates/taskfinder">taskfinder</a> for
16//! more extensive use.
17//!
18//! ```
19//! use tui_dialog::{Dialog, centered_rect};
20//!
21//! pub struct App {
22//!     dialog: Dialog
23//!     text: String,
24//!     exit: bool,
25//! }
26//!
27//! // Initialize the app.
28//! let mut app = App {
29//!     dialog: Dialog::default(),
30//!     text: "Hello world!".to_string(),
31//!     exit: false,
32//! };
33//!
34//! // Then in main loop of app...
35//! while !app.exit {
36//!     terminal.draw(|frame| render(frame, app))?;
37//!
38//!     // Handle user input.
39//!     match event::read()? {
40//!         Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
41//!             // Pass all `key_event.code`s to a dialog if open.
42//!             // (The dialog handles closing itself, when the user presses `Enter` or `Esc`.)
43//!             if app.dialog.open {
44//!                 app.dialog.key_action(&key_event.code);
45//!                 if app.dialog.submitted {
46//!                     // Here is where you'd do something more significant.
47//!                     app.text = app.dialog.submitted_input.clone();
48//!                 }
49//!             // Otherwise handle them here.
50//!             } else {
51//!                 match key_event.code {
52//!                     KeyCode::Char('q') => app.exit = true,
53//!                     // Your app needs to open the dialog.
54//!                     KeyCode::Char('d') => app.dialog.open = true,
55//!                     _ => (),
56//!                 }
57//!             }
58//!         }
59//!         _ => (),
60//!     };
61//! }
62//!
63//! // Rendering the dialog...
64//! fn render(frame: &mut Frame, app: &mut App) {
65//!   // ...
66//!   let dialog_area = centered_rect(frame.area(), 45, 5, 0, 0);
67//!   // There is no need to clear the area first - the widget handles that itself. Just render it.
68//!   // (See <https://ratatui.rs/recipes/layout/center-a-widget/#popups>.)
69//!   frame.render_widget(app.dialog, dialog_area);
70//! }
71//! ```
72
73use std::mem;
74
75use crossterm::event::KeyCode;
76use ratatui::{
77    buffer::Buffer,
78    layout::{Alignment, Constraint, Layout, Rect},
79    prelude::Stylize,
80    style::{Color, Style},
81    text::{Line, Span},
82    widgets::{Block, Borders, Clear, Paragraph, Widget},
83};
84
85/// The default title at the bottom of the widget.
86pub const BOTTOM_TITLE: &str = "Press Enter to submit or Esc to abort";
87
88/// The data structure for the dialog.
89#[derive(PartialEq, Default, Clone)]
90pub struct Dialog {
91    /// Whether or not the dialog box is open.
92    pub open: bool,
93    /// Whether or not input has been submitted.
94    pub submitted: bool,
95    /// The text being written into the dialog box when it's open. This field can be used to
96    /// pre-populate the dialog with a value before it is opened. It will be cleared when the
97    /// user presses `Esc` or `Enter`.
98    pub working_input: String,
99    /// The text that has been written and is submitted for use when the user presses `Enter`. Any
100    /// surrounding whitespace will be trimmed.
101    pub submitted_input: String,
102    cursor_position: usize,
103    title_top: Option<String>,
104    title_bottom: Option<String>,
105    borders: Option<Borders>,
106    style: Option<Style>,
107}
108
109impl Dialog {
110    /// Respond to key press.
111    pub fn key_action(&mut self, key_code: &KeyCode) {
112        self.submitted = false;
113        match key_code {
114            KeyCode::Char(to_insert) => self.insert_char(*to_insert),
115            KeyCode::Backspace => self.backspace(),
116            KeyCode::Delete => self.delete(),
117            KeyCode::End => self.end(),
118            KeyCode::Home => self.home(),
119            KeyCode::Left => self.move_cursor_left(),
120            KeyCode::Right => self.move_cursor_right(),
121            KeyCode::Enter => {
122                // Take the existing working_input and replace any previously submitted input.
123                // Working input is thus set to default (empty string).
124                self.submitted_input = mem::take(&mut self.working_input);
125                self.submitted_input = self.submitted_input.trim().to_string();
126
127                // Mark input as being submitted and close the dialog box, resetting cursor
128                // position for next use.
129                self.submitted = true;
130                self.open = false;
131                self.cursor_position = 0;
132            }
133            KeyCode::Esc => {
134                self.working_input.clear();
135                self.open = false;
136                self.cursor_position = 0;
137            }
138            _ => (),
139        }
140    }
141
142    /// Set the top title of the block surrounding the widget.
143    ///
144    /// If the method is not used, there will be no top title.
145    pub fn title_top(&mut self, title: &str) -> Self {
146        self.title_top = Some(title.to_owned());
147        self.clone()
148    }
149
150    /// Set the bottom title of the block surrounding the widget.
151    ///
152    /// If the method is not used, the bottom title will default to [`BOTTOM_TITLE`].
153    pub fn title_bottom(&mut self, title: &str) -> Self {
154        self.title_bottom = Some(title.to_owned());
155        self.clone()
156    }
157
158    /// Set borders of the block surrounding the widget.
159    ///
160    /// If the method is not used, the borders will default to [`ratatui::widgets::Borders::ALL`].
161    pub fn borders(&mut self, borders: Borders) -> Self {
162        self.borders = Some(borders);
163        self.clone()
164    }
165
166    /// Set the style of the widget.
167    ///
168    /// If the method is not used, the style will be the default with a
169    /// [`ratatui::style::Color::DarkGray`] background.
170    pub fn style(&mut self, style: Style) -> Self {
171        self.style = Some(style);
172        self.clone()
173    }
174
175    /// Render working input of the dialog, showing cursor position.
176    fn render_working_input(&self) -> Line<'_> {
177        // Get working input, adding a space after it (to show cursor position).
178        let text = format!("{} ", self.working_input);
179        let text_len = text.chars().count();
180        let text = text.chars();
181
182        // Split the text up into before cursor, under cursor, and after cursor.
183        let before_cursor = Span::raw(text.clone().take(self.cursor_position).collect::<String>());
184
185        let under_cursor = Span::raw(
186            text.clone()
187                .skip(self.cursor_position)
188                .take(1)
189                .collect::<String>(),
190        )
191        .reversed();
192
193        let after_cursor = if self.cursor_position != text_len {
194            Span::raw(
195                text.clone()
196                    .skip(self.cursor_position + 1)
197                    .collect::<String>(),
198            )
199        } else {
200            Span::raw("")
201        };
202
203        Line::from(vec![before_cursor, under_cursor, after_cursor])
204    }
205
206    fn move_cursor_left(&mut self) {
207        let cursor_moved_left = self.cursor_position.saturating_sub(1);
208        self.cursor_position = self.clamp_cursor(cursor_moved_left);
209    }
210
211    fn move_cursor_right(&mut self) {
212        let cursor_moved_right = self.cursor_position.saturating_add(1);
213        self.cursor_position = self.clamp_cursor(cursor_moved_right);
214    }
215
216    fn insert_char(&mut self, new_char: char) {
217        self.working_input.insert(self.cursor_position, new_char);
218        self.move_cursor_right();
219    }
220
221    fn backspace(&mut self) {
222        if self.cursor_position != 0 {
223            let current_index = self.cursor_position;
224            let from_left_to_current_index = current_index - 1;
225
226            // Get all characters before the selected character.
227            let before_char_to_delete = self.working_input.chars().take(from_left_to_current_index);
228
229            // Get all characters after selected character.
230            let after_char_to_delete = self.working_input.chars().skip(current_index);
231
232            // Put all characters together except the selected one, thus removing it.
233            self.working_input = before_char_to_delete.chain(after_char_to_delete).collect();
234            self.move_cursor_left();
235        }
236    }
237
238    fn delete(&mut self) {
239        let current_index = self.cursor_position;
240
241        // Get all characters before the selected character.
242        let before_char_to_delete = self.working_input.chars().take(current_index);
243
244        // Get all characters after selected character.
245        let after_char_to_delete = self.working_input.chars().skip(current_index + 1);
246
247        // Put all characters together except the selected one, thus removing it.
248        self.working_input = before_char_to_delete.chain(after_char_to_delete).collect();
249    }
250
251    fn end(&mut self) {
252        self.cursor_position = self.working_input.chars().count();
253    }
254
255    fn home(&mut self) {
256        self.cursor_position = 0;
257    }
258
259    fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
260        new_cursor_pos.clamp(0, self.working_input.len())
261    }
262}
263
264impl Widget for Dialog {
265    fn render(mut self, area: Rect, buf: &mut Buffer) {
266        if self.open {
267            Clear.render(area, buf);
268
269            let mut dialog_block = Block::default().title_alignment(Alignment::Center);
270
271            if let Some(ref mut v) = self.title_top {
272                dialog_block = dialog_block.title_top(mem::take(v))
273            }
274
275            dialog_block = if let Some(ref mut v) = self.title_bottom {
276                dialog_block.title_bottom(mem::take(v))
277            } else {
278                dialog_block.title_bottom(BOTTOM_TITLE)
279            };
280
281            dialog_block = if let Some(ref mut v) = self.borders {
282                dialog_block.borders(mem::take(v))
283            } else {
284                dialog_block.borders(Borders::ALL)
285            };
286
287            dialog_block = if let Some(ref mut v) = self.style {
288                dialog_block.style(mem::take(v))
289            } else {
290                dialog_block.style(Style::default().bg(Color::DarkGray))
291            };
292
293            Paragraph::new(self.render_working_input())
294                .block(dialog_block)
295                .render(area, buf)
296        }
297    }
298}
299
300/// Create a centered [`Rect`] to place the dialog in.
301///
302/// `frame.area()` will typically be used for the r (Rect) parameter.
303///
304/// To offset horizontally or vertically, pass in negative values to go left or
305/// up, and positive values to go right or down. Use 0 for these parameters for no offset.
306///
307/// Based on <https://ratatui.rs/how-to/layout/center-a-rect/>.
308pub fn centered_rect(
309    r: Rect,
310    width: u16,
311    height: u16,
312    horizontal_offset: i16,
313    vertical_offset: i16,
314) -> Rect {
315    // Make the vertical layout first. `index` is the part of the layout the corresponds to the
316    // rect we're building.
317    let (dialog_layout, index) = if vertical_offset.is_negative() {
318        (
319            Layout::vertical([
320                Constraint::Fill(1),
321                Constraint::Length(height),
322                Constraint::Fill(1),
323                Constraint::Length(vertical_offset.unsigned_abs()),
324            ])
325            .split(r),
326            1,
327        )
328    } else {
329        (
330            Layout::vertical([
331                Constraint::Length(vertical_offset as u16),
332                Constraint::Fill(1),
333                Constraint::Length(height),
334                Constraint::Fill(1),
335            ])
336            .split(r),
337            2,
338        )
339    };
340
341    // Now use that to do horizontal.
342    if horizontal_offset.is_negative() {
343        Layout::horizontal([
344            Constraint::Fill(1),
345            Constraint::Length(width),
346            Constraint::Fill(1),
347            Constraint::Length(horizontal_offset.unsigned_abs()),
348        ])
349        .split(dialog_layout[index])[1]
350    } else {
351        Layout::horizontal([
352            Constraint::Length(horizontal_offset as u16),
353            Constraint::Fill(1),
354            Constraint::Length(width),
355            Constraint::Fill(1),
356        ])
357        .split(dialog_layout[index])[2]
358    }
359}