Skip to main content

hefesto_widgets/text_input/
mod.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::Rect,
4    style::{Color, Style},
5    text::{Line, Span},
6    widgets::{Block, Borders, Paragraph, StatefulWidget, Widget},
7};
8
9use crate::BorderType;
10use crate::{BORDER_GRAY, TEXT_PADDING};
11
12#[derive(Clone, Default)]
13pub struct TextInputState {
14    pub content: String,
15    pub cursor: usize,
16}
17
18impl TextInputState {
19    pub fn insert_char(&mut self, c: char) {
20        self.content.insert(self.cursor, c);
21        self.cursor += c.len_utf8();
22    }
23
24    pub fn delete_before(&mut self) {
25        if let Some((prev, _)) = self.content[..self.cursor].char_indices().last() {
26            self.cursor = prev;
27            self.content.remove(self.cursor);
28        }
29    }
30
31    pub fn delete_at(&mut self) {
32        if self.cursor < self.content.len() {
33            if let Some(c) = self.content[self.cursor..].chars().next() {
34                let end = self.cursor + c.len_utf8();
35                self.content.drain(self.cursor..end);
36            }
37        }
38    }
39
40    pub fn cursor_left(&mut self) {
41        if let Some((prev, _)) = self.content[..self.cursor].char_indices().last() {
42            self.cursor = prev;
43        }
44    }
45
46    pub fn cursor_right(&mut self) {
47        if self.cursor < self.content.len() {
48            if let Some(c) = self.content[self.cursor..].chars().next() {
49                self.cursor += c.len_utf8();
50            }
51        }
52    }
53
54    pub fn cursor_home(&mut self) {
55        self.cursor = 0;
56    }
57
58    pub fn cursor_end(&mut self) {
59        self.cursor = self.content.len();
60    }
61}
62
63#[derive(Clone)]
64pub struct TextInput<'a> {
65    cursor_style: Style,
66    text_style: Style,
67    border_style: Style,
68    borders: Borders,
69    border_type: BorderType,
70    placeholder: Option<&'a str>,
71    bg_color: Option<Color>,
72    rows: u16,
73    cols: Option<u16>,
74    scroll_padding: u16,
75    scroll_reserve: u16,
76}
77
78impl<'a> TextInput<'a> {
79    pub fn new() -> Self {
80        Self {
81            cursor_style: Style::new().fg(Color::Black).bg(Color::White),
82            text_style: Style::new().fg(Color::White),
83            border_style: Style::new().fg(BORDER_GRAY),
84            borders: Borders::NONE,
85            border_type: BorderType::None,
86            placeholder: None,
87            bg_color: None,
88            rows: 1,
89            cols: None,
90            scroll_padding: 8,
91            scroll_reserve: 8,
92        }
93    }
94
95    pub fn cursor_style(mut self, style: Style) -> Self {
96        self.cursor_style = style;
97        self
98    }
99
100    pub fn text_style(mut self, style: Style) -> Self {
101        self.text_style = style;
102        self
103    }
104
105    pub fn border_style(mut self, style: Style) -> Self {
106        self.border_style = style;
107        self
108    }
109
110    pub fn borders(mut self, borders: Borders) -> Self {
111        self.borders = borders;
112        self
113    }
114
115    pub fn border_type(mut self, border_type: BorderType) -> Self {
116        self.border_type = border_type;
117        self
118    }
119
120    pub fn placeholder(mut self, placeholder: &'a str) -> Self {
121        self.placeholder = Some(placeholder);
122        self
123    }
124
125    pub fn bg_color(mut self, color: Color) -> Self {
126        self.bg_color = Some(color);
127        self
128    }
129
130    pub fn rows(mut self, rows: u16) -> Self {
131        self.rows = rows;
132        self
133    }
134
135    pub fn cols(mut self, cols: u16) -> Self {
136        self.cols = Some(cols);
137        self
138    }
139
140    pub fn scroll_padding(mut self, padding: u16) -> Self {
141        self.scroll_padding = padding;
142        self
143    }
144
145    pub fn scroll_reserve(mut self, reserve: u16) -> Self {
146        self.scroll_reserve = reserve;
147        self
148    }
149
150    pub fn required_height(&self) -> u16 {
151        let has_top = self.borders.contains(Borders::TOP);
152        let has_bottom = self.borders.contains(Borders::BOTTOM);
153        let border_lines = (has_top as u16) + (has_bottom as u16);
154        self.rows + border_lines
155    }
156}
157
158impl Default for TextInput<'_> {
159    fn default() -> Self {
160        Self::new()
161    }
162}
163
164impl StatefulWidget for TextInput<'_> {
165    type State = TextInputState;
166
167    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
168        let has_border = !matches!(self.border_type, BorderType::None);
169        let effective_borders = if has_border { self.borders } else { Borders::NONE };
170
171        let ratatui_border_type = if has_border {
172            self.border_type.to_ratatui()
173        } else {
174            ratatui::widgets::BorderType::Plain
175        };
176
177        let block = Block::bordered()
178            .borders(effective_borders)
179            .border_type(ratatui_border_type)
180            .border_style(self.border_style)
181            .padding(TEXT_PADDING);
182
183        let inner = block.inner(area);
184        block.render(area, buf);
185
186        if let Some(bg) = self.bg_color {
187            buf.set_style(inner, Style::new().bg(bg));
188        }
189
190        let display = if state.content.is_empty() {
191            self.placeholder.unwrap_or("")
192        } else {
193            &state.content
194        };
195
196        let inner_width = inner.width as usize;
197        let cursor_byte = state.cursor.min(display.len());
198        let cursor_byte = if display.is_char_boundary(cursor_byte) {
199            cursor_byte
200        } else {
201            let mut i = cursor_byte;
202            while i > 0 && !display.is_char_boundary(i) {
203                i -= 1;
204            }
205            i
206        };
207        let cursor_char = display[..cursor_byte].chars().count();
208        let total_chars = display.chars().count();
209
210        let sp = self.scroll_padding as usize;
211
212        let scroll_char = if inner_width > sp {
213            cursor_char
214                .saturating_sub(inner_width.saturating_sub(sp))
215                .min(total_chars.saturating_sub(inner_width.saturating_sub(sp)))
216        } else {
217            0
218        };
219
220        let visible: String = display.chars().skip(scroll_char).take(inner_width).collect();
221        let visible_chars = visible.chars().count();
222
223        let visible_cursor = cursor_char.saturating_sub(scroll_char).min(visible_chars);
224        let before: String = visible.chars().take(visible_cursor).collect();
225        let at = visible.chars().nth(visible_cursor);
226        let after: String = visible.chars().skip(visible_cursor + 1).collect();
227
228        let mut spans: Vec<Span<'_>> = Vec::with_capacity(inner_width + 1);
229
230        if !before.is_empty() {
231            spans.push(Span::styled(before, self.text_style));
232        }
233
234        match at {
235            Some(c) => spans.push(Span::styled(c.to_string(), self.cursor_style)),
236            None => spans.push(Span::styled(" ", self.cursor_style)),
237        }
238
239        if !after.is_empty() {
240            spans.push(Span::styled(after, self.text_style));
241        }
242
243        let text_rect = if self.rows > 1 && inner.height >= self.rows {
244            Rect { x: inner.x, y: inner.y, width: inner.width, height: 1 }
245        } else {
246            inner
247        };
248
249        Paragraph::new(Line::from(spans)).render(text_rect, buf);
250    }
251}
252#[cfg(test)]
253mod tests;