Skip to main content

datui_lib/widgets/text_input/
mod.rs

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