Skip to main content

datui_lib/widgets/text_input/
mod.rs

1//! The text entry field used everywhere. Single-line inputs submit on Enter and recall
2//! history with arrows; multi-line ones insert newlines and recall with Ctrl-P/Ctrl-N;
3//! statements submit on Enter, break lines on Alt+Enter and wrap. Editing belongs to
4//! [`crate::widgets::textarea::TextArea`]; this adds theming, focus and history.
5
6pub mod history;
7
8use crate::logging::LogFailure;
9use color_eyre::Result;
10use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
11use ratatui::{
12    buffer::Buffer,
13    layout::Rect,
14    style::{Color, Modifier, Style},
15    widgets::Widget,
16};
17
18use crate::cache::CacheManager;
19use crate::config::Theme;
20use crate::widgets::textarea::{CursorMove, TextArea};
21
22use history::InputHistory;
23
24/// What a key press did to the input, for the caller to act on.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum TextInputEvent {
27    /// The key was handled internally; nothing for the caller to do.
28    None,
29    /// Enter was pressed on a single-line input.
30    Submit,
31    /// Esc was pressed.
32    Cancel,
33    /// The value was replaced by an entry from the history.
34    HistoryChanged,
35}
36
37/// Whether the field holds one line or many.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum TextInputMode {
40    /// Enter submits, and the value never contains a newline.
41    #[default]
42    SingleLine,
43    /// Enter inserts a line break.
44    MultiLine,
45    /// Enter submits and Alt+Enter breaks the line: a statement that is run,
46    /// long enough to want several lines. Long lines wrap instead of
47    /// scrolling, and ↑/↓ move between rows before they recall history.
48    Statement,
49}
50
51/// A themed, optionally history-backed text field.
52#[derive(Debug, Clone)]
53pub struct TextInput {
54    mode: TextInputMode,
55    textarea: TextArea,
56    /// Mirror of the editor contents, so callers can borrow the value cheaply.
57    /// Rewritten by [`TextInput::sync`] after every mutation.
58    value: String,
59    history: InputHistory,
60    text_color: Option<Color>,
61    background_color: Option<Color>,
62    cursor_color: Option<Color>,
63    /// Text under the cursor block; the theme picks it, never this widget.
64    cursor_text: Option<Color>,
65    /// How selected text is drawn; the theme picks it.
66    selection_style: Option<Style>,
67    focused: bool,
68    /// The value is one the form proposed, untouched since. While it holds,
69    /// the whole value is selected whenever the field has focus.
70    suggested: bool,
71}
72
73impl Default for TextInput {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl TextInput {
80    /// A single-line field.
81    pub fn new() -> Self {
82        Self::with_mode(TextInputMode::SingleLine)
83    }
84
85    /// A multi-line field.
86    pub fn multiline() -> Self {
87        Self::with_mode(TextInputMode::MultiLine)
88    }
89
90    /// A field for a statement: Enter runs it, Alt+Enter breaks the line.
91    pub fn statement() -> Self {
92        Self::with_mode(TextInputMode::Statement)
93    }
94
95    fn with_mode(mode: TextInputMode) -> Self {
96        let mut input = Self {
97            mode,
98            textarea: TextArea::new(),
99            value: String::new(),
100            history: InputHistory::new(1000),
101            text_color: None,
102            background_color: None,
103            cursor_color: None,
104            cursor_text: None,
105            selection_style: None,
106            focused: false,
107            suggested: false,
108        };
109        input.textarea.set_wrap(mode == TextInputMode::Statement);
110        input.apply_styles();
111        input
112    }
113
114    /// Whether the field holds one line or many.
115    #[cfg(test)]
116    pub fn mode(&self) -> TextInputMode {
117        self.mode
118    }
119
120    fn is_single_line(&self) -> bool {
121        self.mode == TextInputMode::SingleLine
122    }
123
124    /// Whether Enter hands the value to the caller rather than typing.
125    fn submits_on_enter(&self) -> bool {
126        self.mode != TextInputMode::MultiLine
127    }
128
129    /// Set the text colour.
130    #[cfg(test)]
131    pub fn with_text_color(mut self, color: Color) -> Self {
132        self.text_color = Some(color);
133        self.apply_styles();
134        self
135    }
136
137    /// Set the background colour of the input area.
138    #[cfg(test)]
139    pub fn with_background(mut self, color: Color) -> Self {
140        self.background_color = Some(color);
141        self.apply_styles();
142        self
143    }
144
145    /// Take text and cursor colours from the theme.
146    pub fn with_theme(mut self, theme: &Theme) -> Self {
147        self.text_color = Some(theme.text_primary());
148        let cursor = theme.input_cursor();
149        self.cursor_color = Some(cursor);
150        self.cursor_text = Some(theme.cursor_text_for(cursor));
151        self.selection_style = Some(theme.text_selection_style());
152        self.apply_styles();
153        self
154    }
155
156    /// Persist and recall values under `history_id`, which names the cache file.
157    pub fn with_history(mut self, history_id: String) -> Self {
158        self.history.id = Some(history_id);
159        self
160    }
161
162    /// Cap how many entries the history keeps.
163    pub fn with_history_limit(mut self, limit: usize) -> Self {
164        self.history.limit = limit;
165        self
166    }
167
168    /// Push the current colours into the editor, including the cursor style,
169    /// which depends on whether the field has focus.
170    fn apply_styles(&mut self) {
171        let mut style = Style::default();
172        if let Some(color) = self.text_color {
173            style = style.fg(color);
174        }
175        if let Some(color) = self.background_color {
176            style = style.bg(color);
177        }
178        self.textarea.set_style(style);
179        self.textarea.set_cursor_style(self.cursor_style());
180        self.textarea.set_selection_style(
181            self.selection_style
182                .unwrap_or_else(|| Style::default().add_modifier(Modifier::REVERSED)),
183        );
184        self.textarea.set_cursor_visible(self.focused);
185    }
186
187    /// The cursor highlight. A theme that leaves the cursor colour unset falls
188    /// back to reversing the text, which works on every terminal.
189    fn cursor_style(&self) -> Style {
190        match (self.cursor_color, self.cursor_text) {
191            (Some(color), Some(text)) if color != Color::Reset => {
192                Style::default().bg(color).fg(text)
193            }
194            _ => Style::default().add_modifier(Modifier::REVERSED),
195        }
196    }
197
198    /// Show or hide the cursor. Only the focused field draws one.
199    pub fn set_focused(&mut self, focused: bool) {
200        self.focused = focused;
201        self.textarea.set_cursor_visible(focused);
202        if self.suggested {
203            if focused {
204                self.textarea.select_all();
205            } else {
206                self.textarea.cancel_selection();
207            }
208        }
209    }
210
211    pub fn is_focused(&self) -> bool {
212        self.focused
213    }
214
215    /// The current contents. Multi-line values are newline separated.
216    pub fn value(&self) -> &str {
217        &self.value
218    }
219
220    /// Replace the contents, leaving the cursor at the end.
221    pub fn set_value(&mut self, value: impl AsRef<str>) {
222        self.suggested = false;
223        let value = value.as_ref();
224        if self.is_single_line() {
225            self.textarea.set_text(&flatten(value));
226        } else {
227            self.textarea.set_text(value);
228        }
229        self.history.reset_position();
230        self.sync();
231    }
232
233    /// Fill with a proposed default: until the first key it is selected while focused (a
234    /// printable replaces it, Backspace or Delete clears it, movement or Enter keeps it);
235    /// leaving drops the selection.
236    pub fn suggest(&mut self, value: impl AsRef<str>) {
237        self.set_value(value);
238        self.suggested = !self.value.is_empty();
239        if self.suggested && self.focused {
240            self.textarea.select_all();
241        }
242    }
243
244    /// Whether the value is still the untouched default from [`TextInput::suggest`].
245    pub fn is_suggested(&self) -> bool {
246        self.suggested
247    }
248
249    /// Select the whole value, so the next printable replaces it while any
250    /// cursor movement drops the selection and edits in place.
251    pub fn select_all(&mut self) {
252        self.textarea.select_all();
253    }
254
255    /// Empty the field and stop any history walk in progress.
256    pub fn clear(&mut self) {
257        self.suggested = false;
258        self.textarea.clear();
259        self.history.reset_position();
260        self.sync();
261    }
262
263    pub fn is_empty(&self) -> bool {
264        self.value.is_empty()
265    }
266
267    /// Cursor position as a character offset into [`TextInput::value`].
268    pub fn cursor(&self) -> usize {
269        let (row, col) = self.textarea.cursor();
270        self.textarea
271            .lines()
272            .iter()
273            .take(row)
274            .map(|line| line.chars().count() + 1)
275            .sum::<usize>()
276            + col
277    }
278
279    /// Move the cursor to a character offset into [`TextInput::value`].
280    #[cfg(test)]
281    pub fn set_cursor(&mut self, cursor: usize) {
282        let (row, col) = self.line_col_of(cursor);
283        self.textarea.set_cursor(row, col);
284    }
285
286    /// Line the cursor is on, counting from zero.
287    pub fn cursor_line(&self) -> usize {
288        self.textarea.cursor().0
289    }
290
291    /// Character offset of the cursor within its line.
292    pub fn cursor_col(&self) -> usize {
293        self.textarea.cursor().1
294    }
295
296    /// Move the cursor to a line and column, clamped into the text.
297    #[cfg(test)]
298    pub fn set_cursor_line_col(&mut self, line: usize, col: usize) {
299        self.textarea.set_cursor(line, col);
300    }
301
302    /// Move the cursor up or down by whole lines, clamped at the ends.
303    pub fn move_cursor_by_lines(&mut self, delta: isize) {
304        let movement = if delta < 0 {
305            CursorMove::UpBy(delta.unsigned_abs())
306        } else {
307            CursorMove::DownBy(delta as usize)
308        };
309        self.textarea.move_cursor(movement);
310    }
311
312    /// Number of lines in the value; always at least one.
313    pub fn line_count(&self) -> usize {
314        self.textarea.line_count()
315    }
316
317    /// The text of one line, if it exists.
318    pub fn line_at(&self, line: usize) -> Option<&str> {
319        self.textarea.line(line)
320    }
321
322    /// Rows the value takes when drawn `width` columns wide: its lines, or
323    /// for a statement, its lines as wrapped.
324    pub fn visual_rows(&self, width: u16) -> usize {
325        self.textarea.visual_rows(width)
326    }
327
328    /// Replace the `count` characters before the cursor with `text`.
329    pub fn replace_before_cursor(&mut self, count: usize, text: &str) {
330        self.suggested = false;
331        self.textarea.replace_before_cursor(count, text);
332        self.history.reset_position();
333        self.sync();
334    }
335
336    /// Scroll position of the last render, as `(row, column)`.
337    #[cfg(test)]
338    pub fn scroll_offsets(&self) -> (usize, usize) {
339        self.textarea.scroll_offsets()
340    }
341
342    /// The history entries loaded so far. Empty until something loads them.
343    #[cfg(test)]
344    pub fn history_entries(&self) -> &[String] {
345        self.history.entries()
346    }
347
348    /// Load the history from the cache if it has not been loaded yet.
349    #[cfg(test)]
350    pub fn load_history(&mut self, cache: &CacheManager) -> Result<()> {
351        self.history.ensure_loaded(cache)
352    }
353
354    /// Add the current value to the history and persist it.
355    pub fn save_to_history(&mut self, cache: &CacheManager) -> Result<()> {
356        let value = self.value.clone();
357        self.history.remember(&value, cache)
358    }
359
360    /// Replace the value with an older history entry.
361    pub fn navigate_history_up(&mut self, cache: Option<&CacheManager>) {
362        self.suggested = false;
363        let current = self.value.clone();
364        if let Some(entry) = self.history.older(&current, cache) {
365            self.textarea.set_text(&entry);
366            self.sync();
367        }
368    }
369
370    /// Replace the value with a newer history entry, or the value that was
371    /// being edited before the walk started.
372    pub fn navigate_history_down(&mut self) {
373        self.suggested = false;
374        if let Some(entry) = self.history.newer() {
375            self.textarea.set_text(&entry);
376            self.sync();
377        }
378    }
379
380    /// Handle one key press; `cache` only for inputs with history (`None` skips disk).
381    pub fn handle_key(&mut self, event: &KeyEvent, cache: Option<&CacheManager>) -> TextInputEvent {
382        if event.code == KeyCode::Esc {
383            return TextInputEvent::Cancel;
384        }
385        if !std::mem::take(&mut self.suggested) {
386            return self.apply_key(event, cache);
387        }
388        // The first key settles a suggestion. It acts on the whole value even when
389        // focus never reached the field through `set_focused`.
390        self.textarea.select_all();
391        let selected = self.textarea.selection();
392        let before = self.value.clone();
393        let result = self.apply_key(event, cache);
394        // A key the editor has no use for changes nothing, so the value is still
395        // the form's proposal.
396        self.suggested = self.value == before && self.textarea.selection() == selected;
397        if self.suggested && !self.focused {
398            self.textarea.cancel_selection();
399        }
400        result
401    }
402
403    fn apply_key(&mut self, event: &KeyEvent, cache: Option<&CacheManager>) -> TextInputEvent {
404        let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
405        let alt = event.modifiers.contains(KeyModifiers::ALT);
406        let single_line = self.is_single_line();
407        let statement = self.mode == TextInputMode::Statement;
408        let submits = self.submits_on_enter();
409        let recall = self.history.is_enabled();
410
411        match event.code {
412            // Alt, not Ctrl: legacy terminals send Ctrl+Enter as Ctrl+J, and both
413            // submit.
414            KeyCode::Enter if statement && alt => {
415                self.textarea.insert_newline();
416                self.history.reset_position();
417                self.sync();
418                TextInputEvent::None
419            }
420            KeyCode::Enter if submits => self.submit(cache),
421            KeyCode::Char('m' | 'M') if ctrl && submits => self.submit(cache),
422            // The save chord, in every mode. Without the keyboard-enhancement
423            // protocol some terminals send Ctrl+Enter as Ctrl+J, so the two must
424            // mean the same thing; in a multiline field plain Enter types.
425            KeyCode::Enter | KeyCode::Char('j' | 'J') if ctrl => self.submit(cache),
426            KeyCode::Up if single_line && recall => {
427                self.navigate_history_up(cache);
428                TextInputEvent::HistoryChanged
429            }
430            KeyCode::Down if single_line && recall => {
431                self.navigate_history_down();
432                TextInputEvent::HistoryChanged
433            }
434            // A statement's rows come first; past the top or bottom row the
435            // arrows walk the history as they do in a one-line field.
436            KeyCode::Up | KeyCode::Down if statement && event.modifiers.is_empty() => {
437                let up = event.code == KeyCode::Up;
438                let moved =
439                    self.textarea
440                        .move_cursor(if up { CursorMove::Up } else { CursorMove::Down });
441                if moved || !recall {
442                    self.sync();
443                    return TextInputEvent::None;
444                }
445                if up {
446                    self.navigate_history_up(cache);
447                } else {
448                    self.navigate_history_down();
449                }
450                TextInputEvent::HistoryChanged
451            }
452            KeyCode::Char('p' | 'P') if ctrl && recall => {
453                self.navigate_history_up(cache);
454                TextInputEvent::HistoryChanged
455            }
456            KeyCode::Char('n' | 'N') if ctrl && recall => {
457                self.navigate_history_down();
458                TextInputEvent::HistoryChanged
459            }
460            _ => {
461                if self.textarea.input(event) {
462                    self.history.reset_position();
463                }
464                self.sync();
465                TextInputEvent::None
466            }
467        }
468    }
469
470    fn submit(&mut self, cache: Option<&CacheManager>) -> TextInputEvent {
471        self.textarea.cancel_selection();
472        if let Some(cache) = cache {
473            self.save_to_history(cache).or_log("save input history");
474        }
475        TextInputEvent::Submit
476    }
477
478    /// Refresh the mirrored value, collapsing a single-line field back onto one
479    /// line if an edit somehow introduced a break.
480    fn sync(&mut self) {
481        if self.is_single_line() && self.textarea.line_count() > 1 {
482            let flattened = flatten(&self.textarea.text());
483            self.textarea.set_text(&flattened);
484        }
485        self.value = self.textarea.text();
486    }
487
488    /// Split a character offset into the value into a line and column.
489    #[cfg(test)]
490    fn line_col_of(&self, cursor: usize) -> (usize, usize) {
491        let mut remaining = cursor;
492        for (row, line) in self.textarea.lines().iter().enumerate() {
493            let len = line.chars().count();
494            if remaining <= len {
495                return (row, remaining);
496            }
497            remaining -= len + 1;
498        }
499        let last = self.textarea.line_count() - 1;
500        (last, self.textarea.line(last).unwrap_or("").chars().count())
501    }
502}
503
504impl Widget for &TextInput {
505    fn render(self, area: Rect, buf: &mut Buffer) {
506        (&self.textarea).render(area, buf);
507    }
508}
509
510/// Collapse line breaks so a single-line field stays on one line.
511fn flatten(value: &str) -> String {
512    value.replace(['\n', '\r'], " ")
513}
514
515#[cfg(test)]
516mod tests;