pub mod history;
use crate::logging::LogFailure;
use color_eyre::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::Widget,
};
use crate::cache::CacheManager;
use crate::config::Theme;
use crate::widgets::textarea::{CursorMove, TextArea};
use history::InputHistory;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextInputEvent {
None,
Submit,
Cancel,
HistoryChanged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TextInputMode {
#[default]
SingleLine,
MultiLine,
Statement,
}
#[derive(Debug, Clone)]
pub struct TextInput {
mode: TextInputMode,
textarea: TextArea,
value: String,
history: InputHistory,
text_color: Option<Color>,
background_color: Option<Color>,
cursor_color: Option<Color>,
cursor_text: Option<Color>,
selection_style: Option<Style>,
focused: bool,
suggested: bool,
}
impl Default for TextInput {
fn default() -> Self {
Self::new()
}
}
impl TextInput {
pub fn new() -> Self {
Self::with_mode(TextInputMode::SingleLine)
}
pub fn multiline() -> Self {
Self::with_mode(TextInputMode::MultiLine)
}
pub fn statement() -> Self {
Self::with_mode(TextInputMode::Statement)
}
fn with_mode(mode: TextInputMode) -> Self {
let mut input = Self {
mode,
textarea: TextArea::new(),
value: String::new(),
history: InputHistory::new(1000),
text_color: None,
background_color: None,
cursor_color: None,
cursor_text: None,
selection_style: None,
focused: false,
suggested: false,
};
input.textarea.set_wrap(mode == TextInputMode::Statement);
input.apply_styles();
input
}
#[cfg(test)]
pub fn mode(&self) -> TextInputMode {
self.mode
}
fn is_single_line(&self) -> bool {
self.mode == TextInputMode::SingleLine
}
fn submits_on_enter(&self) -> bool {
self.mode != TextInputMode::MultiLine
}
#[cfg(test)]
pub fn with_text_color(mut self, color: Color) -> Self {
self.text_color = Some(color);
self.apply_styles();
self
}
#[cfg(test)]
pub fn with_background(mut self, color: Color) -> Self {
self.background_color = Some(color);
self.apply_styles();
self
}
pub fn with_theme(mut self, theme: &Theme) -> Self {
self.text_color = Some(theme.text_primary());
let cursor = theme.input_cursor();
self.cursor_color = Some(cursor);
self.cursor_text = Some(theme.cursor_text_for(cursor));
self.selection_style = Some(theme.text_selection_style());
self.apply_styles();
self
}
pub fn with_history(mut self, history_id: String) -> Self {
self.history.id = Some(history_id);
self
}
pub fn with_history_limit(mut self, limit: usize) -> Self {
self.history.limit = limit;
self
}
fn apply_styles(&mut self) {
let mut style = Style::default();
if let Some(color) = self.text_color {
style = style.fg(color);
}
if let Some(color) = self.background_color {
style = style.bg(color);
}
self.textarea.set_style(style);
self.textarea.set_cursor_style(self.cursor_style());
self.textarea.set_selection_style(
self.selection_style
.unwrap_or_else(|| Style::default().add_modifier(Modifier::REVERSED)),
);
self.textarea.set_cursor_visible(self.focused);
}
fn cursor_style(&self) -> Style {
match (self.cursor_color, self.cursor_text) {
(Some(color), Some(text)) if color != Color::Reset => {
Style::default().bg(color).fg(text)
}
_ => Style::default().add_modifier(Modifier::REVERSED),
}
}
pub fn set_focused(&mut self, focused: bool) {
self.focused = focused;
self.textarea.set_cursor_visible(focused);
if self.suggested {
if focused {
self.textarea.select_all();
} else {
self.textarea.cancel_selection();
}
}
}
pub fn is_focused(&self) -> bool {
self.focused
}
pub fn value(&self) -> &str {
&self.value
}
pub fn set_value(&mut self, value: impl AsRef<str>) {
self.suggested = false;
let value = value.as_ref();
if self.is_single_line() {
self.textarea.set_text(&flatten(value));
} else {
self.textarea.set_text(value);
}
self.history.reset_position();
self.sync();
}
pub fn suggest(&mut self, value: impl AsRef<str>) {
self.set_value(value);
self.suggested = !self.value.is_empty();
if self.suggested && self.focused {
self.textarea.select_all();
}
}
pub fn is_suggested(&self) -> bool {
self.suggested
}
pub fn select_all(&mut self) {
self.textarea.select_all();
}
pub fn clear(&mut self) {
self.suggested = false;
self.textarea.clear();
self.history.reset_position();
self.sync();
}
pub fn is_empty(&self) -> bool {
self.value.is_empty()
}
pub fn cursor(&self) -> usize {
let (row, col) = self.textarea.cursor();
self.textarea
.lines()
.iter()
.take(row)
.map(|line| line.chars().count() + 1)
.sum::<usize>()
+ col
}
#[cfg(test)]
pub fn set_cursor(&mut self, cursor: usize) {
let (row, col) = self.line_col_of(cursor);
self.textarea.set_cursor(row, col);
}
pub fn cursor_line(&self) -> usize {
self.textarea.cursor().0
}
pub fn cursor_col(&self) -> usize {
self.textarea.cursor().1
}
#[cfg(test)]
pub fn set_cursor_line_col(&mut self, line: usize, col: usize) {
self.textarea.set_cursor(line, col);
}
pub fn move_cursor_by_lines(&mut self, delta: isize) {
let movement = if delta < 0 {
CursorMove::UpBy(delta.unsigned_abs())
} else {
CursorMove::DownBy(delta as usize)
};
self.textarea.move_cursor(movement);
}
pub fn line_count(&self) -> usize {
self.textarea.line_count()
}
pub fn line_at(&self, line: usize) -> Option<&str> {
self.textarea.line(line)
}
pub fn visual_rows(&self, width: u16) -> usize {
self.textarea.visual_rows(width)
}
pub fn replace_before_cursor(&mut self, count: usize, text: &str) {
self.suggested = false;
self.textarea.replace_before_cursor(count, text);
self.history.reset_position();
self.sync();
}
#[cfg(test)]
pub fn scroll_offsets(&self) -> (usize, usize) {
self.textarea.scroll_offsets()
}
#[cfg(test)]
pub fn history_entries(&self) -> &[String] {
self.history.entries()
}
#[cfg(test)]
pub fn load_history(&mut self, cache: &CacheManager) -> Result<()> {
self.history.ensure_loaded(cache)
}
pub fn save_to_history(&mut self, cache: &CacheManager) -> Result<()> {
let value = self.value.clone();
self.history.remember(&value, cache)
}
pub fn navigate_history_up(&mut self, cache: Option<&CacheManager>) {
self.suggested = false;
let current = self.value.clone();
if let Some(entry) = self.history.older(¤t, cache) {
self.textarea.set_text(&entry);
self.sync();
}
}
pub fn navigate_history_down(&mut self) {
self.suggested = false;
if let Some(entry) = self.history.newer() {
self.textarea.set_text(&entry);
self.sync();
}
}
pub fn handle_key(&mut self, event: &KeyEvent, cache: Option<&CacheManager>) -> TextInputEvent {
if event.code == KeyCode::Esc {
return TextInputEvent::Cancel;
}
if !std::mem::take(&mut self.suggested) {
return self.apply_key(event, cache);
}
self.textarea.select_all();
let selected = self.textarea.selection();
let before = self.value.clone();
let result = self.apply_key(event, cache);
self.suggested = self.value == before && self.textarea.selection() == selected;
if self.suggested && !self.focused {
self.textarea.cancel_selection();
}
result
}
fn apply_key(&mut self, event: &KeyEvent, cache: Option<&CacheManager>) -> TextInputEvent {
let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
let alt = event.modifiers.contains(KeyModifiers::ALT);
let single_line = self.is_single_line();
let statement = self.mode == TextInputMode::Statement;
let submits = self.submits_on_enter();
let recall = self.history.is_enabled();
match event.code {
KeyCode::Enter if statement && alt => {
self.textarea.insert_newline();
self.history.reset_position();
self.sync();
TextInputEvent::None
}
KeyCode::Enter if submits => self.submit(cache),
KeyCode::Char('m' | 'M') if ctrl && submits => self.submit(cache),
KeyCode::Enter | KeyCode::Char('j' | 'J') if ctrl => self.submit(cache),
KeyCode::Up if single_line && recall => {
self.navigate_history_up(cache);
TextInputEvent::HistoryChanged
}
KeyCode::Down if single_line && recall => {
self.navigate_history_down();
TextInputEvent::HistoryChanged
}
KeyCode::Up | KeyCode::Down if statement && event.modifiers.is_empty() => {
let up = event.code == KeyCode::Up;
let moved =
self.textarea
.move_cursor(if up { CursorMove::Up } else { CursorMove::Down });
if moved || !recall {
self.sync();
return TextInputEvent::None;
}
if up {
self.navigate_history_up(cache);
} else {
self.navigate_history_down();
}
TextInputEvent::HistoryChanged
}
KeyCode::Char('p' | 'P') if ctrl && recall => {
self.navigate_history_up(cache);
TextInputEvent::HistoryChanged
}
KeyCode::Char('n' | 'N') if ctrl && recall => {
self.navigate_history_down();
TextInputEvent::HistoryChanged
}
_ => {
if self.textarea.input(event) {
self.history.reset_position();
}
self.sync();
TextInputEvent::None
}
}
}
fn submit(&mut self, cache: Option<&CacheManager>) -> TextInputEvent {
self.textarea.cancel_selection();
if let Some(cache) = cache {
self.save_to_history(cache).or_log("save input history");
}
TextInputEvent::Submit
}
fn sync(&mut self) {
if self.is_single_line() && self.textarea.line_count() > 1 {
let flattened = flatten(&self.textarea.text());
self.textarea.set_text(&flattened);
}
self.value = self.textarea.text();
}
#[cfg(test)]
fn line_col_of(&self, cursor: usize) -> (usize, usize) {
let mut remaining = cursor;
for (row, line) in self.textarea.lines().iter().enumerate() {
let len = line.chars().count();
if remaining <= len {
return (row, remaining);
}
remaining -= len + 1;
}
let last = self.textarea.line_count() - 1;
(last, self.textarea.line(last).unwrap_or("").chars().count())
}
}
impl Widget for &TextInput {
fn render(self, area: Rect, buf: &mut Buffer) {
(&self.textarea).render(area, buf);
}
}
fn flatten(value: &str) -> String {
value.replace(['\n', '\r'], " ")
}
#[cfg(test)]
mod tests;