use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
layout::Rect,
text::{Line, Span},
widgets::Paragraph,
Frame,
};
use crate::Theme;
#[derive(Debug, Clone)]
pub enum FieldInput {
Text(String),
Integer(i64),
Float(f64),
Boolean(bool),
Enum { options: Vec<String>, selected: usize },
List(Vec<String>),
}
#[derive(Debug, Clone)]
pub struct FormField {
pub label: String,
pub input: FieldInput,
pub required: bool,
pub description: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FormEvent {
None,
Submit,
Cancel,
}
pub struct FormState {
pub fields: Vec<FormField>,
pub focused: usize,
cursors: Vec<usize>,
}
impl FormState {
pub fn new(fields: Vec<FormField>) -> Self {
let len = fields.len();
Self { fields, focused: 0, cursors: vec![0; len] }
}
pub fn focus_next(&mut self) {
if self.focused + 1 < self.fields.len() {
self.focused += 1;
}
}
pub fn focus_prev(&mut self) {
self.focused = self.focused.saturating_sub(1);
}
pub fn handle_key(&mut self, key: KeyEvent) -> FormEvent {
if self.fields.is_empty() {
return FormEvent::None;
}
match key.code {
KeyCode::Tab if !key.modifiers.contains(KeyModifiers::SHIFT) => {
self.focus_next();
FormEvent::None
}
KeyCode::Tab | KeyCode::BackTab => {
self.focus_prev();
FormEvent::None
}
KeyCode::Down => { self.focus_next(); FormEvent::None }
KeyCode::Up => { self.focus_prev(); FormEvent::None }
KeyCode::Esc => FormEvent::Cancel,
KeyCode::Enter => {
if self.focused + 1 >= self.fields.len() {
FormEvent::Submit
} else {
self.focus_next();
FormEvent::None
}
}
_ => {
self.handle_field_key(key);
FormEvent::None
}
}
}
fn handle_field_key(&mut self, key: KeyEvent) {
let idx = self.focused;
match &mut self.fields[idx].input {
FieldInput::Text(s) => match key.code {
KeyCode::Char(c) => { s.push(c); self.cursors[idx] = s.len(); }
KeyCode::Backspace => { s.pop(); self.cursors[idx] = s.len(); }
_ => {}
},
FieldInput::Integer(n) => match key.code {
KeyCode::Char(c) if c.is_ascii_digit() || (c == '-' && *n == 0) => {
let mut s = n.to_string();
s.push(c);
if let Ok(v) = s.parse::<i64>() { *n = v; }
self.cursors[idx] = n.to_string().len();
}
KeyCode::Backspace => {
let mut s = n.to_string();
s.pop();
*n = s.parse::<i64>().unwrap_or(0);
self.cursors[idx] = n.to_string().len();
}
_ => {}
},
FieldInput::Float(f) => match key.code {
KeyCode::Char(c) if c.is_ascii_digit() || c == '.' || (c == '-' && *f == 0.0) => {
let mut s = format!("{}", f);
s.push(c);
if let Ok(v) = s.parse::<f64>() { *f = v; }
self.cursors[idx] = format!("{}", f).len();
}
KeyCode::Backspace => {
let mut s = format!("{}", f);
s.pop();
*f = s.parse::<f64>().unwrap_or(0.0);
self.cursors[idx] = format!("{}", f).len();
}
_ => {}
},
FieldInput::Boolean(b) => {
if key.code == KeyCode::Char(' ') {
*b = !*b;
}
}
FieldInput::Enum { options, selected } => match key.code {
KeyCode::Right | KeyCode::Char('l') => {
if !options.is_empty() {
*selected = (*selected + 1) % options.len();
}
}
KeyCode::Left | KeyCode::Char('h') => {
if !options.is_empty() && *selected > 0 {
*selected -= 1;
} else if !options.is_empty() {
*selected = options.len() - 1;
}
}
_ => {}
},
FieldInput::List(_) => {
}
}
}
pub fn fields(&self) -> &[FormField] {
&self.fields
}
pub fn into_fields(self) -> Vec<FormField> {
self.fields
}
}
pub fn render_form(f: &mut Frame, area: Rect, state: &FormState, theme: &Theme) {
if area.height == 0 {
return;
}
let mut y = area.y;
for (idx, field) in state.fields.iter().enumerate() {
if y >= area.y + area.height {
break;
}
let focused = idx == state.focused;
let label_style = if focused { theme.tab_active } else { theme.hint };
let bracket_style = if focused { theme.border_focused } else { theme.border_unfocused };
let label = if field.required {
format!("{}*", field.label)
} else {
field.label.clone()
};
let input_repr = field_repr(&field.input);
let line = Line::from(vec![
Span::styled(format!("{}: ", label), label_style),
Span::styled(input_repr, bracket_style),
]);
let row = Rect { x: area.x, y, width: area.width, height: 1 };
f.render_widget(Paragraph::new(line), row);
y += 1;
if focused {
if let Some(desc) = &field.description {
if y < area.y + area.height {
let hint_row = Rect { x: area.x + 2, y, width: area.width.saturating_sub(2), height: 1 };
f.render_widget(
Paragraph::new(Line::from(Span::styled(desc.clone(), theme.hint))),
hint_row,
);
y += 1;
}
}
}
y += 1;
}
}
fn field_repr(input: &FieldInput) -> String {
match input {
FieldInput::Text(s) => format!("[ {} ]", if s.is_empty() { "_" } else { s }),
FieldInput::Integer(n) => format!("[ {} ]", n),
FieldInput::Float(f) => format!("[ {} ]", f),
FieldInput::Boolean(b) => if *b { "[x]".into() } else { "[ ]".into() },
FieldInput::Enum { options, selected } => {
if options.is_empty() {
"< >".into()
} else {
format!("< {} >", options[*selected])
}
}
FieldInput::List(items) => {
if items.is_empty() {
"[ (empty) ]".into()
} else {
format!("[ {} ]", items.join(", "))
}
}
}
}