datui_lib/widgets/text_input/
mod.rs1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum TextInputEvent {
36 None,
38 Submit,
40 Cancel,
42 HistoryChanged,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum TextInputMode {
49 #[default]
51 SingleLine,
52 MultiLine,
54 Statement,
58}
59
60#[derive(Debug, Clone)]
62pub struct TextInput {
63 mode: TextInputMode,
64 textarea: TextArea,
65 value: String,
68 history: InputHistory,
69 text_color: Option<Color>,
70 background_color: Option<Color>,
71 cursor_color: Option<Color>,
72 cursor_text: Option<Color>,
74 selection_style: Option<Style>,
76 focused: bool,
77 suggested: bool,
80}
81
82impl Default for TextInput {
83 fn default() -> Self {
84 Self::new()
85 }
86}
87
88impl TextInput {
89 pub fn new() -> Self {
91 Self::with_mode(TextInputMode::SingleLine)
92 }
93
94 pub fn multiline() -> Self {
96 Self::with_mode(TextInputMode::MultiLine)
97 }
98
99 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 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 fn submits_on_enter(&self) -> bool {
134 self.mode != TextInputMode::MultiLine
135 }
136
137 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 pub fn with_background(mut self, color: Color) -> Self {
146 self.background_color = Some(color);
147 self.apply_styles();
148 self
149 }
150
151 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 pub fn with_history(mut self, history_id: String) -> Self {
164 self.history.id = Some(history_id);
165 self
166 }
167
168 pub fn with_history_limit(mut self, limit: usize) -> Self {
170 self.history.limit = limit;
171 self
172 }
173
174 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 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 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 pub fn value(&self) -> &str {
223 &self.value
224 }
225
226 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 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 pub fn is_suggested(&self) -> bool {
253 self.suggested
254 }
255
256 pub fn select_all(&mut self) {
259 self.textarea.select_all();
260 }
261
262 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 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 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 pub fn cursor_line(&self) -> usize {
294 self.textarea.cursor().0
295 }
296
297 pub fn cursor_col(&self) -> usize {
299 self.textarea.cursor().1
300 }
301
302 pub fn set_cursor_line_col(&mut self, line: usize, col: usize) {
304 self.textarea.set_cursor(line, col);
305 }
306
307 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 pub fn line_count(&self) -> usize {
319 self.textarea.line_count()
320 }
321
322 pub fn line_at(&self, line: usize) -> Option<&str> {
324 self.textarea.line(line)
325 }
326
327 pub fn visual_rows(&self, width: u16) -> usize {
330 self.textarea.visual_rows(width)
331 }
332
333 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 pub fn scroll_offsets(&self) -> (usize, usize) {
343 self.textarea.scroll_offsets()
344 }
345
346 pub fn history_entries(&self) -> &[String] {
348 self.history.entries()
349 }
350
351 pub fn load_history(&mut self, cache: &CacheManager) -> Result<()> {
353 self.history.ensure_loaded(cache)
354 }
355
356 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 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(¤t, cache) {
367 self.textarea.set_text(&entry);
368 self.sync();
369 }
370 }
371
372 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 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 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 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 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 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 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 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 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
514fn flatten(value: &str) -> String {
516 value.replace(['\n', '\r'], " ")
517}
518
519#[cfg(test)]
520mod tests;