use crate::core::buffer::Buffer;
use crate::core::color::Color;
use crate::core::rect::Rect;
use crate::style::Style;
use crate::widgets::Widget;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FormAction {
None,
Submit,
Cancel,
FocusNext,
FocusPrevious,
}
#[derive(Debug, Clone)]
pub struct TextInput {
pub label: String,
pub value: String,
pub cursor: usize,
pub masked: bool,
pub focused: bool,
pub style: Style,
pub focus_style: Style,
pub placeholder: String,
}
impl TextInput {
pub fn new(label: &str) -> Self {
Self {
label: label.to_string(),
value: String::new(),
cursor: 0,
masked: false,
focused: false,
style: Style::new().fg(Color::rgb(201, 209, 217)),
focus_style: Style::new()
.fg(Color::WHITE)
.bg(Color::rgb(31, 111, 235))
.bold(),
placeholder: String::new(),
}
}
pub fn with_value(mut self, value: &str) -> Self {
self.value = value.to_string();
self.cursor = self.value.chars().count();
self
}
pub fn with_placeholder(mut self, placeholder: &str) -> Self {
self.placeholder = placeholder.to_string();
self
}
pub fn masked(mut self, masked: bool) -> Self {
self.masked = masked;
self
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub fn handle_event(&mut self, event: &Event) -> FormAction {
match event {
Event::Key(key) => self.handle_key(*key),
Event::Paste(text) => {
self.insert_str(text);
FormAction::None
}
_ => FormAction::None,
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> FormAction {
match key.code {
KeyCode::Esc => FormAction::Cancel,
KeyCode::Enter => FormAction::Submit,
KeyCode::Tab => FormAction::FocusNext,
KeyCode::BackTab => FormAction::FocusPrevious,
KeyCode::Left => {
self.cursor = self.cursor.saturating_sub(1);
FormAction::None
}
KeyCode::Right => {
self.cursor = (self.cursor + 1).min(self.value.chars().count());
FormAction::None
}
KeyCode::Home => {
self.cursor = 0;
FormAction::None
}
KeyCode::End => {
self.cursor = self.value.chars().count();
FormAction::None
}
KeyCode::Backspace => {
if self.cursor > 0 {
self.cursor -= 1;
remove_char(&mut self.value, self.cursor);
}
FormAction::None
}
KeyCode::Delete => {
if self.cursor < self.value.chars().count() {
remove_char(&mut self.value, self.cursor);
}
FormAction::None
}
KeyCode::Char(c) => {
if !key.modifiers.contains(KeyModifiers::CONTROL) {
self.insert_char(c);
}
FormAction::None
}
_ => FormAction::None,
}
}
fn insert_str(&mut self, text: &str) {
for ch in text.chars() {
if ch != '\n' && ch != '\r' {
self.insert_char(ch);
}
}
}
fn insert_char(&mut self, ch: char) {
let byte_idx = byte_index_for_char(&self.value, self.cursor);
self.value.insert(byte_idx, ch);
self.cursor += 1;
}
pub fn rendered_value(&self) -> String {
if self.value.is_empty() {
self.placeholder.clone()
} else if self.masked {
"*".repeat(self.value.chars().count())
} else {
self.value.clone()
}
}
}
impl Widget for TextInput {
fn render(&self, buffer: &mut Buffer, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
}
let style = if self.focused {
self.focus_style
} else {
self.style
};
let fg = style.fg_or_default();
let bg = style.bg;
let label = format!("{}: ", self.label);
buffer.fill(area, ' ', fg, bg);
buffer.set_str(area.x as usize, area.y as usize, &label, fg, bg);
let value_x = area.x as usize + label.chars().count();
if value_x < area.right() as usize {
let max = area.right() as usize - value_x;
let display: String = self.rendered_value().chars().take(max).collect();
let value_color = if self.value.is_empty() {
Color::rgb(110, 118, 129)
} else {
fg
};
buffer.set_str(value_x, area.y as usize, &display, value_color, bg);
if self.focused {
let cursor_x = (value_x + self.cursor).min(area.right() as usize - 1);
buffer.set(
cursor_x,
area.y as usize,
crate::core::buffer::Cell::new('â–ˆ', fg, bg),
);
}
}
}
}
#[derive(Debug, Clone)]
pub struct Dropdown {
pub label: String,
pub options: Vec<String>,
pub selected: usize,
pub focused: bool,
pub style: Style,
pub focus_style: Style,
}
impl Dropdown {
pub fn new(label: &str, options: Vec<String>) -> Self {
Self {
label: label.to_string(),
options,
selected: 0,
focused: false,
style: Style::new().fg(Color::rgb(201, 209, 217)),
focus_style: Style::new()
.fg(Color::WHITE)
.bg(Color::rgb(31, 111, 235))
.bold(),
}
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub fn selected_value(&self) -> Option<&str> {
self.options.get(self.selected).map(String::as_str)
}
pub fn handle_key(&mut self, key: KeyEvent) -> FormAction {
match key.code {
KeyCode::Esc => FormAction::Cancel,
KeyCode::Enter => FormAction::Submit,
KeyCode::Tab => FormAction::FocusNext,
KeyCode::BackTab => FormAction::FocusPrevious,
KeyCode::Up | KeyCode::Left => {
self.selected = self.selected.saturating_sub(1);
FormAction::None
}
KeyCode::Down | KeyCode::Right => {
if !self.options.is_empty() {
self.selected = (self.selected + 1).min(self.options.len() - 1);
}
FormAction::None
}
_ => FormAction::None,
}
}
}
impl Widget for Dropdown {
fn render(&self, buffer: &mut Buffer, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
}
let style = if self.focused {
self.focus_style
} else {
self.style
};
let fg = style.fg_or_default();
let bg = style.bg;
let value = self.selected_value().unwrap_or("none");
let text = format!("{}: < {} >", self.label, value);
buffer.fill(area, ' ', fg, bg);
buffer.set_str(area.x as usize, area.y as usize, &text, fg, bg);
}
}
#[derive(Debug, Clone)]
pub enum FormField {
TextInput(TextInput),
Dropdown(Dropdown),
Toggle { label: String, value: bool },
}
#[derive(Debug, Clone)]
pub struct Form {
pub title: String,
pub fields: Vec<FormField>,
pub focus: usize,
pub status: Option<String>,
}
impl Form {
pub fn new(title: &str) -> Self {
Self {
title: title.to_string(),
fields: Vec::new(),
focus: 0,
status: None,
}
}
pub fn with_field(mut self, field: FormField) -> Self {
self.fields.push(field);
self
}
pub fn set_status(&mut self, status: impl Into<String>) {
self.status = Some(status.into());
}
pub fn focus_next(&mut self) {
if !self.fields.is_empty() {
self.focus = (self.focus + 1) % self.fields.len();
}
}
pub fn focus_previous(&mut self) {
if !self.fields.is_empty() {
self.focus = if self.focus == 0 {
self.fields.len() - 1
} else {
self.focus - 1
};
}
}
pub fn handle_event(&mut self, event: &Event) -> FormAction {
let action = match self.fields.get_mut(self.focus) {
Some(FormField::TextInput(input)) => input.handle_event(event),
Some(FormField::Dropdown(dropdown)) => match event {
Event::Key(key) => dropdown.handle_key(*key),
_ => FormAction::None,
},
Some(FormField::Toggle { value, .. }) => match event {
Event::Key(key) => match key.code {
KeyCode::Char(' ') | KeyCode::Enter => {
*value = !*value;
FormAction::None
}
KeyCode::Tab => FormAction::FocusNext,
KeyCode::BackTab => FormAction::FocusPrevious,
KeyCode::Esc => FormAction::Cancel,
_ => FormAction::None,
},
_ => FormAction::None,
},
None => FormAction::None,
};
match action {
FormAction::FocusNext => {
self.focus_next();
FormAction::None
}
FormAction::FocusPrevious => {
self.focus_previous();
FormAction::None
}
other => other,
}
}
}
impl Widget for Form {
fn render(&self, buffer: &mut Buffer, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
}
buffer.fill(
area,
' ',
Color::rgb(201, 209, 217),
Some(Color::rgb(13, 17, 23)),
);
buffer.set_str_bold(
area.x as usize,
area.y as usize,
&self.title,
Color::WHITE,
Some(Color::rgb(13, 17, 23)),
);
for (idx, field) in self.fields.iter().enumerate() {
let y = area.y + 2 + idx as u16;
if y >= area.bottom() {
break;
}
let row = Rect::new(area.x, y, area.width, 1);
match field {
FormField::TextInput(input) => {
input.clone().focused(idx == self.focus).render(buffer, row)
}
FormField::Dropdown(dropdown) => dropdown
.clone()
.focused(idx == self.focus)
.render(buffer, row),
FormField::Toggle { label, value } => {
let prefix = if idx == self.focus { ">" } else { " " };
let checked = if *value { "on" } else { "off" };
buffer.set_str(
row.x as usize,
row.y as usize,
&format!("{} {}: {}", prefix, label, checked),
Color::WHITE,
Some(Color::rgb(13, 17, 23)),
);
}
}
}
if let Some(status) = &self.status {
let y = area.bottom().saturating_sub(1);
buffer.set_str(
area.x as usize,
y as usize,
status,
Color::rgb(255, 178, 72),
Some(Color::rgb(13, 17, 23)),
);
}
}
}
fn byte_index_for_char(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(idx, _)| idx)
.unwrap_or(s.len())
}
fn remove_char(s: &mut String, char_idx: usize) {
let start = byte_index_for_char(s, char_idx);
let end = byte_index_for_char(s, char_idx + 1);
if start < end && start < s.len() {
s.replace_range(start..end, "");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_input_supports_insert_and_backspace() {
let mut input = TextInput::new("Key");
input.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
input.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
input.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(input.value, "a");
}
#[test]
fn text_input_masks_value() {
let input = TextInput::new("Secret").with_value("token").masked(true);
assert_eq!(input.rendered_value(), "*****");
}
#[test]
fn dropdown_changes_selection() {
let mut dropdown = Dropdown::new("Provider", vec!["A".into(), "B".into()]);
dropdown.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(dropdown.selected_value(), Some("B"));
}
}