use crate::actor::{InputEvent, KeyCode};
use crate::buffer::{Buffer, Cell, Rgb};
use crate::layout::Rect;
use super::traits::Widget;
#[derive(Debug, Clone)]
pub struct TextInputConfig {
pub fg: Rgb,
pub bg: Rgb,
pub cursor_fg: Rgb,
pub placeholder: String,
pub placeholder_fg: Rgb,
pub prompt: String,
pub prompt_fg: Rgb,
}
impl Default for TextInputConfig {
fn default() -> Self {
Self {
fg: Rgb::WHITE,
bg: Rgb::new(30, 30, 30),
cursor_fg: Rgb::new(0, 255, 255),
placeholder: String::new(),
placeholder_fg: Rgb::new(100, 100, 100),
prompt: String::from("> "),
prompt_fg: Rgb::new(0, 255, 255),
}
}
}
#[derive(Debug)]
pub struct TextInput {
content: String,
cursor: usize,
bounds: Rect,
focused: bool,
config: TextInputConfig,
frame: u64,
dirty: bool,
}
impl TextInput {
pub fn new(bounds: Rect) -> Self {
Self {
content: String::new(),
cursor: 0,
bounds,
focused: true,
config: TextInputConfig::default(),
frame: 0,
dirty: true,
}
}
pub const fn with_config(bounds: Rect, config: TextInputConfig) -> Self {
Self {
content: String::new(),
cursor: 0,
bounds,
focused: true,
config,
frame: 0,
dirty: true,
}
}
pub fn content(&self) -> &str {
&self.content
}
pub fn set_content(&mut self, content: &str) {
self.content = content.to_string();
self.cursor = self.content.len();
self.dirty = true;
}
pub fn clear(&mut self) {
self.content.clear();
self.cursor = 0;
self.dirty = true;
}
pub const fn is_empty(&self) -> bool {
self.content.is_empty()
}
pub const fn set_focused(&mut self, focused: bool) {
self.focused = focused;
self.dirty = true;
}
pub const fn is_focused(&self) -> bool {
self.focused
}
pub const fn tick(&mut self) {
self.frame = self.frame.wrapping_add(1);
if self.focused && self.frame.is_multiple_of(15) {
self.dirty = true;
}
}
fn insert_char(&mut self, c: char) {
self.content.insert(self.cursor, c);
self.cursor += c.len_utf8();
self.dirty = true;
}
fn backspace(&mut self) {
if self.cursor > 0 {
let prev = self.content[..self.cursor]
.char_indices()
.last()
.map_or(0, |(i, _)| i);
self.content.remove(prev);
self.cursor = prev;
self.dirty = true;
}
}
fn delete(&mut self) {
if self.cursor < self.content.len() {
self.content.remove(self.cursor);
self.dirty = true;
}
}
fn cursor_left(&mut self) {
if self.cursor > 0 {
self.cursor = self.content[..self.cursor]
.char_indices()
.last()
.map_or(0, |(i, _)| i);
self.dirty = true;
}
}
fn cursor_right(&mut self) {
if self.cursor < self.content.len() {
if let Some(c) = self.content[self.cursor..].chars().next() {
self.cursor += c.len_utf8();
self.dirty = true;
}
}
}
const fn cursor_home(&mut self) {
if self.cursor != 0 {
self.cursor = 0;
self.dirty = true;
}
}
const fn cursor_end(&mut self) {
let end = self.content.len();
if self.cursor != end {
self.cursor = end;
self.dirty = true;
}
}
}
impl Widget for TextInput {
fn bounds(&self) -> Rect {
self.bounds
}
fn set_bounds(&mut self, bounds: Rect) {
self.bounds = bounds;
self.dirty = true;
}
fn render(&self, buffer: &mut Buffer) {
let x = self.bounds.x;
let y = self.bounds.y;
let width = self.bounds.width as usize;
for i in 0..self.bounds.width {
buffer.set(x + i, y, Cell::new(' ').with_bg(self.config.bg));
}
let prompt_len = self.config.prompt.chars().count();
for (i, c) in self.config.prompt.chars().enumerate() {
#[allow(clippy::cast_possible_truncation)]
let px = x + i as u16;
if (px as usize) < x as usize + width {
buffer.set(px, y, Cell::new(c)
.with_fg(self.config.prompt_fg)
.with_bg(self.config.bg));
}
}
#[allow(clippy::cast_possible_truncation)]
let text_start = x + prompt_len as u16;
let text_width = width.saturating_sub(prompt_len);
if self.content.is_empty() && !self.config.placeholder.is_empty() {
for (i, c) in self.config.placeholder.chars().take(text_width).enumerate() {
#[allow(clippy::cast_possible_truncation)]
let px = text_start + i as u16;
buffer.set(px, y, Cell::new(c)
.with_fg(self.config.placeholder_fg)
.with_bg(self.config.bg));
}
} else {
let cursor_char_pos = self.content[..self.cursor].chars().count();
let content_chars: Vec<char> = self.content.chars().collect();
let scroll_offset = if cursor_char_pos >= text_width {
cursor_char_pos - text_width + 1
} else {
0
};
for (i, &c) in content_chars.iter().skip(scroll_offset).take(text_width).enumerate() {
#[allow(clippy::cast_possible_truncation)]
let px = text_start + i as u16;
let is_cursor = self.focused
&& (i + scroll_offset) == cursor_char_pos
&& self.frame % 30 < 15;
if is_cursor {
buffer.set(px, y, Cell::new(c)
.with_fg(self.config.bg)
.with_bg(self.config.cursor_fg));
} else {
buffer.set(px, y, Cell::new(c)
.with_fg(self.config.fg)
.with_bg(self.config.bg));
}
}
#[allow(clippy::cast_possible_truncation)]
let cursor_visual_pos = cursor_char_pos.saturating_sub(scroll_offset) as u16;
#[allow(clippy::cast_possible_truncation)]
let text_width_u16 = text_width as u16;
if self.focused
&& cursor_char_pos == content_chars.len()
&& cursor_visual_pos < text_width_u16
&& self.frame % 30 < 15
{
let cx = text_start + cursor_visual_pos;
buffer.set(cx, y, Cell::new('█')
.with_fg(self.config.cursor_fg)
.with_bg(self.config.bg));
}
}
}
fn handle_input(&mut self, event: &InputEvent) -> bool {
if !self.focused {
return false;
}
if let InputEvent::Key { code, modifiers } = event {
match code {
KeyCode::Char(c) => {
if !modifiers.control && !modifiers.alt {
self.insert_char(*c);
return true;
}
}
KeyCode::Backspace => {
self.backspace();
return true;
}
KeyCode::Delete => {
self.delete();
return true;
}
KeyCode::Left => {
self.cursor_left();
return true;
}
KeyCode::Right => {
self.cursor_right();
return true;
}
KeyCode::Home => {
self.cursor_home();
return true;
}
KeyCode::End => {
self.cursor_end();
return true;
}
_ => {}
}
}
false
}
fn needs_redraw(&self) -> bool {
self.dirty
}
fn clear_redraw(&mut self) {
self.dirty = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_text_input_basic() {
let mut input = TextInput::new(Rect::new(0, 0, 80, 1));
input.insert_char('H');
input.insert_char('i');
assert_eq!(input.content(), "Hi");
assert_eq!(input.cursor, 2);
}
#[test]
fn test_text_input_backspace() {
let mut input = TextInput::new(Rect::new(0, 0, 80, 1));
input.set_content("Hello");
input.backspace();
assert_eq!(input.content(), "Hell");
}
#[test]
fn test_text_input_cursor_movement() {
let mut input = TextInput::new(Rect::new(0, 0, 80, 1));
input.set_content("Hello");
input.cursor_left();
assert_eq!(input.cursor, 4);
input.cursor_home();
assert_eq!(input.cursor, 0);
input.cursor_end();
assert_eq!(input.cursor, 5);
}
}