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}