tui-dialog 0.5.0

A widget for entering a single line of text in a dialog for Ratatui.
Documentation
#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
#![deny(missing_docs)]

use std::mem;

use crossterm::event::KeyCode;
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui_core::{
    buffer::Buffer,
    layout::{Alignment, Constraint, Layout, Rect},
    style::{Color, Style, Stylize},
    text::{Line, Span},
    widgets::Widget,
};

/// The default title at the bottom of the widget.
pub const BOTTOM_TITLE: &str = "Press Enter to submit or Esc to abort";

/// The data structure for the dialog.
#[derive(PartialEq, Default, Clone)]
pub struct Dialog {
    /// Whether or not the dialog box is open.
    pub open: bool,
    /// Whether or not input has been submitted.
    pub submitted: bool,
    /// The text being written into the dialog box when it's open. This field can be used to
    /// pre-populate the dialog with a value before it is opened. It will be cleared when the
    /// user presses `Esc` or `Enter`.
    pub working_input: String,
    /// The text that has been written and is submitted for use when the user presses `Enter`. Any
    /// surrounding whitespace will be trimmed.
    pub submitted_input: String,
    cursor_position: usize,
    title_top: Option<String>,
    title_bottom: Option<String>,
    borders: Option<Borders>,
    style: Option<Style>,
}

impl Dialog {
    /// Respond to key press.
    pub fn key_action(&mut self, key_code: &KeyCode) {
        self.submitted = false;
        match key_code {
            KeyCode::Char(to_insert) => self.insert_char(*to_insert),
            KeyCode::Backspace => self.backspace(),
            KeyCode::Delete => self.delete(),
            KeyCode::End => self.end(),
            KeyCode::Home => self.home(),
            KeyCode::Left => self.move_cursor_left(),
            KeyCode::Right => self.move_cursor_right(),
            KeyCode::Enter => {
                // Take the existing working_input and replace any previously submitted input.
                // Working input is thus set to default (empty string).
                self.submitted_input = mem::take(&mut self.working_input);
                self.submitted_input = self.submitted_input.trim().to_string();

                // Mark input as being submitted and close the dialog box, resetting cursor
                // position for next use.
                self.submitted = true;
                self.open = false;
                self.cursor_position = 0;
            }
            KeyCode::Esc => {
                self.working_input.clear();
                self.open = false;
                self.cursor_position = 0;
            }
            _ => (),
        }
    }

    /// Set the top title of the block surrounding the widget.
    ///
    /// If the method is not used, there will be no top title.
    pub fn title_top(&mut self, title: &str) -> Self {
        self.title_top = Some(title.to_owned());
        self.clone()
    }

    /// Set the bottom title of the block surrounding the widget.
    ///
    /// If the method is not used, the bottom title will default to [`BOTTOM_TITLE`].
    pub fn title_bottom(&mut self, title: &str) -> Self {
        self.title_bottom = Some(title.to_owned());
        self.clone()
    }

    /// Set borders of the block surrounding the widget.
    ///
    /// If the method is not used, the borders will default to [`ratatui::widgets::Borders::ALL`].
    pub fn borders(&mut self, borders: Borders) -> Self {
        self.borders = Some(borders);
        self.clone()
    }

    /// Set the style of the widget.
    ///
    /// If the method is not used, the style will be the default with a
    /// [`ratatui::style::Color::DarkGray`] background.
    pub fn style(&mut self, style: Style) -> Self {
        self.style = Some(style);
        self.clone()
    }

    /// Render working input of the dialog, showing cursor position.
    fn render_working_input(&self) -> Line<'_> {
        // Get working input, adding a space after it (to show cursor position).
        let text = format!("{} ", self.working_input);
        let text_len = text.chars().count();
        let text = text.chars();

        // Split the text up into before cursor, under cursor, and after cursor.
        let before_cursor = Span::raw(text.clone().take(self.cursor_position).collect::<String>());

        let under_cursor = Span::raw(
            text.clone()
                .skip(self.cursor_position)
                .take(1)
                .collect::<String>(),
        )
        .reversed();

        let after_cursor = if self.cursor_position != text_len {
            Span::raw(
                text.clone()
                    .skip(self.cursor_position + 1)
                    .collect::<String>(),
            )
        } else {
            Span::raw("")
        };

        Line::from(vec![before_cursor, under_cursor, after_cursor])
    }

    fn move_cursor_left(&mut self) {
        let cursor_moved_left = self.cursor_position.saturating_sub(1);
        self.cursor_position = self.clamp_cursor(cursor_moved_left);
    }

    fn move_cursor_right(&mut self) {
        let cursor_moved_right = self.cursor_position.saturating_add(1);
        self.cursor_position = self.clamp_cursor(cursor_moved_right);
    }

    fn insert_char(&mut self, new_char: char) {
        self.working_input.insert(self.cursor_position, new_char);
        self.move_cursor_right();
    }

    fn backspace(&mut self) {
        if self.cursor_position != 0 {
            let current_index = self.cursor_position;
            let from_left_to_current_index = current_index - 1;

            // Get all characters before the selected character.
            let before_char_to_delete = self.working_input.chars().take(from_left_to_current_index);

            // Get all characters after selected character.
            let after_char_to_delete = self.working_input.chars().skip(current_index);

            // Put all characters together except the selected one, thus removing it.
            self.working_input = before_char_to_delete.chain(after_char_to_delete).collect();
            self.move_cursor_left();
        }
    }

    fn delete(&mut self) {
        let current_index = self.cursor_position;

        // Get all characters before the selected character.
        let before_char_to_delete = self.working_input.chars().take(current_index);

        // Get all characters after selected character.
        let after_char_to_delete = self.working_input.chars().skip(current_index + 1);

        // Put all characters together except the selected one, thus removing it.
        self.working_input = before_char_to_delete.chain(after_char_to_delete).collect();
    }

    fn end(&mut self) {
        self.cursor_position = self.working_input.chars().count();
    }

    fn home(&mut self) {
        self.cursor_position = 0;
    }

    fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
        new_cursor_pos.clamp(0, self.working_input.len())
    }
}

impl Widget for Dialog {
    fn render(mut self, area: Rect, buf: &mut Buffer) {
        if self.open {
            Clear.render(area, buf);

            let mut dialog_block = Block::default().title_alignment(Alignment::Center);

            if let Some(ref mut v) = self.title_top {
                dialog_block = dialog_block.title_top(mem::take(v))
            }

            dialog_block = if let Some(ref mut v) = self.title_bottom {
                dialog_block.title_bottom(mem::take(v))
            } else {
                dialog_block.title_bottom(BOTTOM_TITLE)
            };

            dialog_block = if let Some(ref mut v) = self.borders {
                dialog_block.borders(mem::take(v))
            } else {
                dialog_block.borders(Borders::ALL)
            };

            dialog_block = if let Some(ref mut v) = self.style {
                dialog_block.style(mem::take(v))
            } else {
                dialog_block.style(Style::default().bg(Color::DarkGray))
            };

            Paragraph::new(self.render_working_input())
                .block(dialog_block)
                .render(area, buf)
        }
    }
}

/// Create a centered [`Rect`] to place the dialog in.
///
/// `frame.area()` will typically be used for the r (Rect) parameter.
///
/// To offset horizontally or vertically, pass in negative values to go left or
/// up, and positive values to go right or down. Use 0 for these parameters for no offset.
///
/// Based on <https://ratatui.rs/how-to/layout/center-a-rect/>.
pub fn centered_rect(
    r: Rect,
    width: u16,
    height: u16,
    horizontal_offset: i16,
    vertical_offset: i16,
) -> Rect {
    // Make the vertical layout first. `index` is the part of the layout the corresponds to the
    // rect we're building.
    let (dialog_layout, index) = if vertical_offset.is_negative() {
        (
            Layout::vertical([
                Constraint::Fill(1),
                Constraint::Length(height),
                Constraint::Fill(1),
                Constraint::Length(vertical_offset.unsigned_abs()),
            ])
            .split(r),
            1,
        )
    } else {
        (
            Layout::vertical([
                Constraint::Length(vertical_offset as u16),
                Constraint::Fill(1),
                Constraint::Length(height),
                Constraint::Fill(1),
            ])
            .split(r),
            2,
        )
    };

    // Now use that to do horizontal.
    if horizontal_offset.is_negative() {
        Layout::horizontal([
            Constraint::Fill(1),
            Constraint::Length(width),
            Constraint::Fill(1),
            Constraint::Length(horizontal_offset.unsigned_abs()),
        ])
        .split(dialog_layout[index])[1]
    } else {
        Layout::horizontal([
            Constraint::Length(horizontal_offset as u16),
            Constraint::Fill(1),
            Constraint::Length(width),
            Constraint::Fill(1),
        ])
        .split(dialog_layout[index])[2]
    }
}