use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::Widget,
};
use crate::tui::theme;
pub struct TextInput<'a> {
content: &'a str,
cursor: usize,
title: &'a str,
focused: bool,
}
impl<'a> TextInput<'a> {
#[must_use]
pub fn new(content: &'a str, cursor: usize, title: &'a str) -> Self {
Self {
content,
cursor,
title,
focused: false,
}
}
#[must_use]
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
}
impl Widget for TextInput<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let block = theme::block(
self.title,
if self.focused {
theme::BlockVariant::Focused
} else {
theme::BlockVariant::Unfocused
},
);
let inner = block.inner(area);
block.render(area, buf);
if inner.height == 0 || inner.width < 2 {
return;
}
let input_style = if self.focused {
Style::default().fg(Color::White)
} else {
Style::default().fg(Color::Gray)
};
let line = Line::from(Span::styled(self.content, input_style));
buf.set_line(inner.x + 1, inner.y, &line, inner.width.saturating_sub(2));
if self.focused && inner.width > 1 {
#[allow(clippy::cast_possible_truncation)]
let cursor_offset = self.cursor as u16;
let cursor_x = inner.x + 1 + cursor_offset;
if cursor_x < inner.x + inner.width.saturating_sub(1) {
buf[(cursor_x, inner.y)]
.set_style(Style::default().bg(Color::White).fg(Color::Black));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::test_utils::{buffer_to_text, render_to_snapshot};
#[test]
fn renders_empty_input() {
let input = TextInput::new("", 0, "Input").focused(true);
let text = buffer_to_text_widget(input, 30, 3);
assert!(text.contains("Input"), "Text:\n{text}");
}
#[test]
fn renders_content() {
let input = TextInput::new("hello world", 0, "Text").focused(true);
let text = buffer_to_text_widget(input, 30, 3);
assert!(text.contains("hello world"), "Text:\n{text}");
}
#[test]
fn focused_input_has_cyan_border() {
let input = TextInput::new("test", 0, "Focused").focused(true);
let snapshot = render_to_snapshot(input, 30, 3);
assert!(
snapshot.contains("[C]"),
"Focused should have cyan border:\n{snapshot}"
);
}
#[test]
fn unfocused_input_has_gray_border() {
let input = TextInput::new("test", 0, "Unfocused").focused(false);
let snapshot = render_to_snapshot(input, 30, 3);
assert!(
snapshot.contains("[Gy]"),
"Unfocused should have gray border:\n{snapshot}"
);
}
#[test]
fn cursor_renders_at_correct_position() {
let input = TextInput::new("abc", 1, "Cursor").focused(true);
let snapshot = render_to_snapshot(input, 30, 3);
assert!(
snapshot.contains('a'),
"Input should contain 'a':\n{snapshot}"
);
}
#[test]
fn cursor_not_visible_when_unfocused() {
let input = TextInput::new("abc", 1, "NoCursor").focused(false);
let snapshot = render_to_snapshot(input, 30, 3);
assert!(
snapshot.contains("[Gy]a"),
"Unfocused text should be gray:\n{snapshot}"
);
}
fn buffer_to_text_widget<W: Widget>(widget: W, width: u16, height: u16) -> String {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
buffer_to_text(&buf)
}
}