use alloc::borrow::ToOwned as _;
use alloc::string::String;
use retroglyph_core::color::{Color, Style};
use retroglyph_core::text::{split_at_width, width_usize};
use super::StatefulWidget;
use crate::Surface;
use crate::TextInputState;
use crate::Theme;
use crate::text::truncate as truncate_to_cols;
#[derive(Clone, Copy, Debug)]
pub struct TextInput<'a> {
placeholder: Option<&'a str>,
mask: Option<char>,
style: Style,
placeholder_style: Style,
caret_style: Style,
}
impl<'a> TextInput<'a> {
#[must_use]
pub fn new() -> Self {
Self {
placeholder: None,
mask: None,
style: Style::new(),
placeholder_style: Style::new(),
caret_style: Style::new(),
}
.theme(Theme::DARK)
}
#[must_use]
pub const fn placeholder(mut self, placeholder: &'a str) -> Self {
self.placeholder = Some(placeholder);
self
}
#[must_use]
pub const fn mask(mut self, mask: char) -> Self {
self.mask = Some(mask);
self
}
#[must_use]
pub const fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
#[must_use]
pub const fn placeholder_style(mut self, style: Style) -> Self {
self.placeholder_style = style;
self
}
#[must_use]
pub const fn caret_style(mut self, style: Style) -> Self {
self.caret_style = style;
self
}
#[must_use]
pub fn theme(self, theme: Theme) -> Self {
self.theme_on(theme, theme.panel_bg)
}
#[must_use]
pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
self.style = Style::new().fg(theme.fg).bg(bg);
self.placeholder_style = Style::new().fg(theme.dim).bg(bg);
self.caret_style = Style::new().fg(theme.bg).bg(theme.accent);
self
}
}
impl Default for TextInput<'_> {
fn default() -> Self {
Self::new()
}
}
impl StatefulWidget for TextInput<'_> {
type State = TextInputState;
fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State) {
let width = surface.width();
if width == 0 {
return;
}
let value = state.value();
let caret_col =
width_usize(&value[..state.cursor()]).saturating_sub(usize::from(state.scroll()));
let (text, style) = if value.is_empty() {
self.placeholder.map_or_else(
|| (String::new(), self.style),
|placeholder| (placeholder.to_owned(), self.placeholder_style),
)
} else {
let (_, visible) = split_at_width(value, state.scroll());
let visible = self
.mask
.map_or_else(|| visible.to_owned(), |mask| masked(visible, mask));
(visible, self.style)
};
let text = truncate_to_cols(&text, width);
surface.print((0, 0), text, style);
if caret_col < usize::from(width) {
#[allow(clippy::cast_possible_truncation)] let x = caret_col as u16;
let glyph = glyph_at_column(text, caret_col).unwrap_or(' ');
surface.put((x, 0), glyph, self.caret_style);
}
}
}
fn masked(s: &str, mask: char) -> String {
s.chars().map(|_| mask).collect()
}
fn glyph_at_column(s: &str, col: usize) -> Option<char> {
#[allow(clippy::cast_possible_truncation)] let col = col.min(usize::from(u16::MAX)) as u16;
let (_, rest) = split_at_width(s, col);
rest.chars().next()
}
#[cfg(test)]
mod tests {
use retroglyph_core::grid::{Grid, Pos, Rect};
use super::*;
#[test]
fn renders_the_value_and_a_caret_at_the_end() {
let area = Rect::new(0, 0, 10, 1);
let mut grid = Grid::new(10, 1);
let mut state = TextInputState::new();
state.set_value("hi");
TextInput::new().render(&mut Surface::new(&mut grid, area, 0), &mut state);
assert_eq!(grid[Pos::new(0, 0)].glyph(), 'h');
assert_eq!(grid[Pos::new(1, 0)].glyph(), 'i');
assert_eq!(grid[Pos::new(2, 0)].glyph(), ' ');
assert_ne!(
grid[Pos::new(2, 0)].style().background(),
grid[Pos::new(0, 0)].style().background()
);
}
#[test]
fn shows_the_placeholder_only_while_empty() {
let area = Rect::new(0, 0, 10, 1);
let mut grid = Grid::new(10, 1);
let mut state = TextInputState::new();
TextInput::new()
.placeholder("name")
.render(&mut Surface::new(&mut grid, area, 0), &mut state);
assert_eq!(grid[Pos::new(0, 0)].glyph(), 'n');
let mut grid = Grid::new(10, 1);
state.insert('x');
TextInput::new()
.placeholder("name")
.render(&mut Surface::new(&mut grid, area, 0), &mut state);
assert_eq!(grid[Pos::new(0, 0)].glyph(), 'x');
}
#[test]
fn mask_hides_the_real_characters() {
let area = Rect::new(0, 0, 10, 1);
let mut grid = Grid::new(10, 1);
let mut state = TextInputState::new();
state.set_value("secret");
TextInput::new()
.mask('*')
.render(&mut Surface::new(&mut grid, area, 0), &mut state);
assert_eq!(grid[Pos::new(0, 0)].glyph(), '*');
assert_eq!(grid[Pos::new(5, 0)].glyph(), '*');
}
#[test]
fn scroll_offset_shifts_the_visible_window() {
let area = Rect::new(0, 0, 5, 1);
let mut grid = Grid::new(5, 1);
let mut state = TextInputState::new();
state.set_value("hello world");
state.ensure_visible(5);
TextInput::new().render(&mut Surface::new(&mut grid, area, 0), &mut state);
assert_eq!(grid[Pos::new(0, 0)].glyph(), 'o');
assert_eq!(grid[Pos::new(1, 0)].glyph(), 'r');
}
#[test]
fn caret_lands_on_the_correct_column_with_wide_characters() {
let area = Rect::new(0, 0, 10, 1);
let mut grid = Grid::new(10, 1);
let mut state = TextInputState::new();
state.set_value("aあ"); state.move_home();
state.move_right();
TextInput::new().render(&mut Surface::new(&mut grid, area, 0), &mut state);
assert_eq!(grid[Pos::new(1, 0)].glyph(), 'あ');
assert_ne!(
grid[Pos::new(1, 0)].style().background(),
grid[Pos::new(0, 0)].style().background()
);
}
#[test]
fn text_input_caret_does_not_write_past_the_field() {
let mut grid = Grid::new(6, 1);
let mut state = TextInputState::new();
state.set_value("ああ"); state.ensure_visible(4);
Surface::new(&mut grid, Rect::new(0, 0, 6, 1), 0).print((4, 0), "Z", Style::default());
TextInput::new().render(
&mut Surface::new(&mut grid, Rect::new(0, 0, 4, 1), 0),
&mut state,
);
assert_eq!(grid[Pos::new(4, 0)].glyph(), 'Z');
}
}