hefesto-widgets 0.6.8

Ratatui widgets for the Hefesto TUI toolkit: popups, scrollable lists, trees, text input and spinners
Documentation
use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph, StatefulWidget, Widget},
};

use crate::BorderType;
use crate::{BORDER_GRAY, TEXT_PADDING};

#[derive(Clone, Default)]
pub struct TextInputState {
    pub content: String,
    pub cursor: usize,
}

impl TextInputState {
    pub fn insert_char(&mut self, c: char) {
        self.content.insert(self.cursor, c);
        self.cursor += c.len_utf8();
    }

    pub fn delete_before(&mut self) {
        if let Some((prev, _)) = self.content[..self.cursor].char_indices().last() {
            self.cursor = prev;
            self.content.remove(self.cursor);
        }
    }

    pub fn delete_at(&mut self) {
        if self.cursor < self.content.len() {
            if let Some(c) = self.content[self.cursor..].chars().next() {
                let end = self.cursor + c.len_utf8();
                self.content.drain(self.cursor..end);
            }
        }
    }

    pub fn cursor_left(&mut self) {
        if let Some((prev, _)) = self.content[..self.cursor].char_indices().last() {
            self.cursor = prev;
        }
    }

    pub fn cursor_right(&mut self) {
        if self.cursor < self.content.len() {
            if let Some(c) = self.content[self.cursor..].chars().next() {
                self.cursor += c.len_utf8();
            }
        }
    }

    pub fn cursor_home(&mut self) {
        self.cursor = 0;
    }

    pub fn cursor_end(&mut self) {
        self.cursor = self.content.len();
    }
}

#[derive(Clone)]
pub struct TextInput<'a> {
    cursor_style: Style,
    text_style: Style,
    border_style: Style,
    borders: Borders,
    border_type: BorderType,
    placeholder: Option<&'a str>,
    bg_color: Option<Color>,
    rows: u16,
    cols: Option<u16>,
    scroll_padding: u16,
    scroll_reserve: u16,
}

impl<'a> TextInput<'a> {
    pub fn new() -> Self {
        Self {
            cursor_style: Style::new().fg(Color::Black).bg(Color::White),
            text_style: Style::new().fg(Color::White),
            border_style: Style::new().fg(BORDER_GRAY),
            borders: Borders::NONE,
            border_type: BorderType::None,
            placeholder: None,
            bg_color: None,
            rows: 1,
            cols: None,
            scroll_padding: 8,
            scroll_reserve: 8,
        }
    }

    pub fn cursor_style(mut self, style: Style) -> Self {
        self.cursor_style = style;
        self
    }

    pub fn text_style(mut self, style: Style) -> Self {
        self.text_style = style;
        self
    }

    pub fn border_style(mut self, style: Style) -> Self {
        self.border_style = style;
        self
    }

    pub fn borders(mut self, borders: Borders) -> Self {
        self.borders = borders;
        self
    }

    pub fn border_type(mut self, border_type: BorderType) -> Self {
        self.border_type = border_type;
        self
    }

    pub fn placeholder(mut self, placeholder: &'a str) -> Self {
        self.placeholder = Some(placeholder);
        self
    }

    pub fn bg_color(mut self, color: Color) -> Self {
        self.bg_color = Some(color);
        self
    }

    pub fn rows(mut self, rows: u16) -> Self {
        self.rows = rows;
        self
    }

    pub fn cols(mut self, cols: u16) -> Self {
        self.cols = Some(cols);
        self
    }

    pub fn scroll_padding(mut self, padding: u16) -> Self {
        self.scroll_padding = padding;
        self
    }

    pub fn scroll_reserve(mut self, reserve: u16) -> Self {
        self.scroll_reserve = reserve;
        self
    }

    pub fn required_height(&self) -> u16 {
        let has_top = self.borders.contains(Borders::TOP);
        let has_bottom = self.borders.contains(Borders::BOTTOM);
        let border_lines = (has_top as u16) + (has_bottom as u16);
        self.rows + border_lines
    }
}

impl Default for TextInput<'_> {
    fn default() -> Self {
        Self::new()
    }
}

impl StatefulWidget for TextInput<'_> {
    type State = TextInputState;

    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
        let has_border = !matches!(self.border_type, BorderType::None);
        let effective_borders = if has_border { self.borders } else { Borders::NONE };

        let ratatui_border_type = if has_border {
            self.border_type.to_ratatui()
        } else {
            ratatui::widgets::BorderType::Plain
        };

        let block = Block::bordered()
            .borders(effective_borders)
            .border_type(ratatui_border_type)
            .border_style(self.border_style)
            .padding(TEXT_PADDING);

        let inner = block.inner(area);
        block.render(area, buf);

        if let Some(bg) = self.bg_color {
            buf.set_style(inner, Style::new().bg(bg));
        }

        let display = if state.content.is_empty() {
            self.placeholder.unwrap_or("")
        } else {
            &state.content
        };

        let inner_width = inner.width as usize;
        let cursor_byte = state.cursor.min(display.len());
        let cursor_byte = if display.is_char_boundary(cursor_byte) {
            cursor_byte
        } else {
            let mut i = cursor_byte;
            while i > 0 && !display.is_char_boundary(i) {
                i -= 1;
            }
            i
        };
        let cursor_char = display[..cursor_byte].chars().count();
        let total_chars = display.chars().count();

        let sp = self.scroll_padding as usize;

        let scroll_char = if inner_width > sp {
            cursor_char
                .saturating_sub(inner_width.saturating_sub(sp))
                .min(total_chars.saturating_sub(inner_width.saturating_sub(sp)))
        } else {
            0
        };

        let visible: String = display.chars().skip(scroll_char).take(inner_width).collect();
        let visible_chars = visible.chars().count();

        let visible_cursor = cursor_char.saturating_sub(scroll_char).min(visible_chars);
        let before: String = visible.chars().take(visible_cursor).collect();
        let at = visible.chars().nth(visible_cursor);
        let after: String = visible.chars().skip(visible_cursor + 1).collect();

        let mut spans: Vec<Span<'_>> = Vec::with_capacity(inner_width + 1);

        if !before.is_empty() {
            spans.push(Span::styled(before, self.text_style));
        }

        match at {
            Some(c) => spans.push(Span::styled(c.to_string(), self.cursor_style)),
            None => spans.push(Span::styled(" ", self.cursor_style)),
        }

        if !after.is_empty() {
            spans.push(Span::styled(after, self.text_style));
        }

        let text_rect = if self.rows > 1 && inner.height >= self.rows {
            Rect { x: inner.x, y: inner.y, width: inner.width, height: 1 }
        } else {
            inner
        };

        Paragraph::new(Line::from(spans)).render(text_rect, buf);
    }
}
#[cfg(test)]
mod tests;