use alloc::borrow::ToOwned as _;
use alloc::string::String;
use retroglyph_core::color::{Color, Style};
use retroglyph_core::grid::Size;
use retroglyph_core::text::{split_at_width, width, width_usize};
use super::{InteractiveWidget, MinSize, StatefulWidget};
use crate::Surface;
use crate::interact::Density;
use crate::interact::Response;
use crate::interact::Sense;
use crate::state::TextInputState;
use crate::text::truncate as truncate_to_cols;
use crate::theme::Theme;
#[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 TextInput<'_> {
fn draw(&self, surface: &mut Surface<'_>, state: &TextInputState) {
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);
}
}
}
impl MinSize for TextInput<'_> {
fn min_size(&self, density: Density) -> Size {
let content_width = self.placeholder.map_or(0, width).saturating_add(4);
Size::new(content_width, 1).max(density.min_target_size())
}
}
impl StatefulWidget for TextInput<'_> {
type State = TextInputState;
fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State) {
self.draw(surface, state);
}
}
impl<Id> InteractiveWidget<Id> for TextInput<'_> {
type State = TextInputState;
fn sense(&self) -> Sense {
Sense::click()
}
fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State, _response: Response<Id>) {
self.draw(surface, state);
}
}
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, HasSize as _, 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");
StatefulWidget::render(
&TextInput::new(),
&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();
StatefulWidget::render(
&TextInput::new().placeholder("name"),
&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');
StatefulWidget::render(
&TextInput::new().placeholder("name"),
&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");
StatefulWidget::render(
&TextInput::new().mask('*'),
&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);
StatefulWidget::render(
&TextInput::new(),
&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();
StatefulWidget::render(
&TextInput::new(),
&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());
StatefulWidget::render(
&TextInput::new(),
&mut Surface::new(&mut grid, Rect::new(0, 0, 4, 1), 0),
&mut state,
);
assert_eq!(grid[Pos::new(4, 0)].glyph(), 'Z');
}
#[test]
fn interactive_render_draws_the_same_as_stateful_render() {
let area = Rect::new(0, 0, 10, 1);
let mut state = TextInputState::new();
state.set_value("hi");
let mut stateful_grid = Grid::new(10, 1);
StatefulWidget::render(
&TextInput::new(),
&mut Surface::new(&mut stateful_grid, area, 0),
&mut state,
);
let response: Response<()> = Response {
hovered: true,
focused: true,
..Response::default()
};
let mut interactive_grid = Grid::new(10, 1);
InteractiveWidget::render(
&TextInput::new(),
&mut Surface::new(&mut interactive_grid, area, 0),
&mut state,
response,
);
for x in 0..10 {
let pos = Pos::new(x, 0);
assert_eq!(stateful_grid[pos].glyph(), interactive_grid[pos].glyph());
assert_eq!(stateful_grid[pos].style(), interactive_grid[pos].style());
}
}
#[test]
fn sense_is_click_so_a_click_focuses_the_field_through_a_shared_interaction() {
use retroglyph_core::event::{
Event, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use crate::interact::Interaction;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Id {
Name,
}
let area = Rect::new(0, 0, 10, 1);
let mut grid = Grid::new(10, 1);
let mut state = TextInputState::new();
let mut interaction = Interaction::<Id>::new();
let text_input = TextInput::new();
let sense = <TextInput as InteractiveWidget<Id>>::sense(&text_input);
assert_eq!(sense, Sense::click());
interaction.begin_frame();
let response = interaction.interact(area, Id::Name, sense);
InteractiveWidget::render(
&text_input,
&mut Surface::new(&mut grid, area, 0),
&mut state,
response,
);
interaction.end_frame();
let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
Pos::new(2, 0),
KeyModifiers::NONE,
)));
let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
MouseEventKind::Up(MouseButton::Left),
Pos::new(2, 0),
KeyModifiers::NONE,
)));
interaction.begin_frame();
let _ = interaction.interact(area, Id::Name, sense);
interaction.end_frame();
interaction.begin_frame();
let response = interaction.interact(area, Id::Name, sense);
assert!(response.focused());
interaction.end_frame();
}
#[test]
fn min_size_floors_an_empty_placeholder_at_the_density_minimum() {
let size = TextInput::new().min_size(Density::Mouse);
assert_eq!(size, Density::Mouse.min_target_size());
}
#[test]
fn min_size_pads_the_placeholder_by_two_columns_each_side() {
let size = TextInput::new()
.placeholder("Name")
.min_size(Density::Mouse);
assert_eq!(size.width(), 8);
assert_eq!(size.height(), 1);
}
#[test]
fn min_size_grows_taller_for_touch_density() {
let size = TextInput::new().min_size(Density::Touch);
assert_eq!(size, Density::Touch.min_target_size());
}
}