datui_lib/widgets/text_input/
mod.rs1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum TextInputEvent {
27 None,
29 Submit,
31 Cancel,
33 HistoryChanged,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum TextInputMode {
40 #[default]
42 SingleLine,
43 MultiLine,
45 Statement,
49}
50
51#[derive(Debug, Clone)]
53pub struct TextInput {
54 mode: TextInputMode,
55 textarea: TextArea,
56 value: String,
59 history: InputHistory,
60 text_color: Option<Color>,
61 background_color: Option<Color>,
62 cursor_color: Option<Color>,
63 cursor_text: Option<Color>,
65 selection_style: Option<Style>,
67 focused: bool,
68 suggested: bool,
71}
72
73impl Default for TextInput {
74 fn default() -> Self {
75 Self::new()
76 }
77}
78
79impl TextInput {
80 pub fn new() -> Self {
82 Self::with_mode(TextInputMode::SingleLine)
83 }
84
85 pub fn multiline() -> Self {
87 Self::with_mode(TextInputMode::MultiLine)
88 }
89
90 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 #[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 fn submits_on_enter(&self) -> bool {
126 self.mode != TextInputMode::MultiLine
127 }
128
129 #[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 #[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 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 pub fn with_history(mut self, history_id: String) -> Self {
158 self.history.id = Some(history_id);
159 self
160 }
161
162 pub fn with_history_limit(mut self, limit: usize) -> Self {
164 self.history.limit = limit;
165 self
166 }
167
168 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 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 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 pub fn value(&self) -> &str {
217 &self.value
218 }
219
220 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 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 pub fn is_suggested(&self) -> bool {
246 self.suggested
247 }
248
249 pub fn select_all(&mut self) {
252 self.textarea.select_all();
253 }
254
255 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 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 #[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 pub fn cursor_line(&self) -> usize {
288 self.textarea.cursor().0
289 }
290
291 pub fn cursor_col(&self) -> usize {
293 self.textarea.cursor().1
294 }
295
296 #[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 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 pub fn line_count(&self) -> usize {
314 self.textarea.line_count()
315 }
316
317 pub fn line_at(&self, line: usize) -> Option<&str> {
319 self.textarea.line(line)
320 }
321
322 pub fn visual_rows(&self, width: u16) -> usize {
325 self.textarea.visual_rows(width)
326 }
327
328 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 #[cfg(test)]
338 pub fn scroll_offsets(&self) -> (usize, usize) {
339 self.textarea.scroll_offsets()
340 }
341
342 #[cfg(test)]
344 pub fn history_entries(&self) -> &[String] {
345 self.history.entries()
346 }
347
348 #[cfg(test)]
350 pub fn load_history(&mut self, cache: &CacheManager) -> Result<()> {
351 self.history.ensure_loaded(cache)
352 }
353
354 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 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(¤t, cache) {
365 self.textarea.set_text(&entry);
366 self.sync();
367 }
368 }
369
370 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 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 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 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 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 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 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 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 #[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
510fn flatten(value: &str) -> String {
512 value.replace(['\n', '\r'], " ")
513}
514
515#[cfg(test)]
516mod tests;