use std::collections::HashSet;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
Frame,
};
use crate::Theme;
#[derive(Debug, Clone, PartialEq)]
pub enum FieldInput {
Text(String),
Integer(i64),
Float(f64),
Boolean(bool),
Enum { options: Vec<String>, selected: usize },
List(Vec<String>),
ReadOnly(String),
}
#[derive(Debug, Clone)]
pub struct FormField {
pub id: String,
pub label: String,
pub input: FieldInput,
pub required: bool,
pub description: Option<String>,
pub visible: bool,
}
impl FormField {
pub fn new(
id: impl Into<String>,
label: impl Into<String>,
input: FieldInput,
) -> Self {
Self {
id: id.into(),
label: label.into(),
input,
required: false,
description: None,
visible: true,
}
}
pub fn required(mut self, required: bool) -> Self { self.required = required; self }
pub fn description(mut self, d: impl Into<String>) -> Self {
self.description = Some(d.into()); self
}
pub fn visible(mut self, visible: bool) -> Self { self.visible = visible; self }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FormEvent {
None,
FocusMoved,
FieldChanged(String),
Submit,
Cancel,
}
#[derive(Debug, Clone)]
pub struct FormState {
pub fields: Vec<FormField>,
pub focused: usize,
pub cursors: Vec<usize>,
pub touched: HashSet<String>,
}
impl FormState {
pub fn new(fields: Vec<FormField>) -> Self {
let cursors = fields.iter().map(cursor_end_of).collect();
let focused = fields
.iter()
.position(|f| f.visible)
.unwrap_or(0);
Self { fields, focused, cursors, touched: HashSet::new() }
}
pub fn focus_next(&mut self) -> bool {
let from = self.focused;
let n = self.fields.len();
if n == 0 { return false; }
let mut i = from;
while i + 1 < n {
i += 1;
if self.fields[i].visible {
self.on_focus_change(from, i);
return true;
}
}
false
}
pub fn focus_prev(&mut self) -> bool {
let from = self.focused;
let mut i = from;
while i > 0 {
i -= 1;
if self.fields[i].visible {
self.on_focus_change(from, i);
return true;
}
}
false
}
fn on_focus_change(&mut self, from: usize, to: usize) {
if from < self.fields.len() {
self.touched.insert(self.fields[from].id.clone());
}
self.focused = to;
self.cursors[to] = cursor_end_of(&self.fields[to]);
}
pub fn handle_key(&mut self, key: KeyEvent) -> FormEvent {
if self.fields.is_empty() {
return FormEvent::None;
}
match key.code {
KeyCode::Esc => FormEvent::Cancel,
KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
if self.focus_prev() { FormEvent::FocusMoved } else { FormEvent::None }
}
KeyCode::BackTab => {
if self.focus_prev() { FormEvent::FocusMoved } else { FormEvent::None }
}
KeyCode::Tab | KeyCode::Down => {
if self.focus_next() { FormEvent::FocusMoved } else { FormEvent::None }
}
KeyCode::Up => {
if self.focus_prev() { FormEvent::FocusMoved } else { FormEvent::None }
}
KeyCode::Enter => {
if self.is_on_last_visible() {
FormEvent::Submit
} else if self.focus_next() {
FormEvent::FocusMoved
} else {
FormEvent::Submit
}
}
_ => self.handle_field_key(key),
}
}
fn is_on_last_visible(&self) -> bool {
self.fields
.iter()
.enumerate()
.filter(|(_, f)| f.visible)
.last()
.map(|(i, _)| i == self.focused)
.unwrap_or(false)
}
fn handle_field_key(&mut self, key: KeyEvent) -> FormEvent {
let idx = self.focused;
let id = self.fields[idx].id.clone();
let mut changed = false;
match &mut self.fields[idx].input {
FieldInput::Text(s) => {
if edit_text(key, s, &mut self.cursors[idx]) { changed = true; }
}
FieldInput::Integer(n) => {
let mut s = n.to_string();
let mut cur = self.cursors[idx].min(s.chars().count());
if edit_numeric(key, &mut s, &mut cur, true) {
*n = s.parse::<i64>().unwrap_or(*n);
self.cursors[idx] = cur;
changed = true;
}
}
FieldInput::Float(f) => {
let mut s = format!("{}", f);
let mut cur = self.cursors[idx].min(s.chars().count());
if edit_numeric(key, &mut s, &mut cur, false) {
*f = s.parse::<f64>().unwrap_or(*f);
self.cursors[idx] = cur;
changed = true;
}
}
FieldInput::Boolean(b) => match key.code {
KeyCode::Left | KeyCode::Right | KeyCode::Char(' ') => {
*b = !*b;
changed = true;
}
_ => {}
},
FieldInput::Enum { options, selected } => {
if options.is_empty() { return FormEvent::None; }
match key.code {
KeyCode::Right | KeyCode::Char('l') => {
*selected = (*selected + 1) % options.len();
changed = true;
}
KeyCode::Left | KeyCode::Char('h') => {
*selected = if *selected == 0 { options.len() - 1 } else { *selected - 1 };
changed = true;
}
_ => {}
}
}
FieldInput::List(_) | FieldInput::ReadOnly(_) => {}
}
if changed { FormEvent::FieldChanged(id) } else { FormEvent::None }
}
pub fn touch_all(&mut self) {
for f in &self.fields {
self.touched.insert(f.id.clone());
}
}
pub fn untouch_all(&mut self) { self.touched.clear(); }
}
fn cursor_end_of(f: &FormField) -> usize {
match &f.input {
FieldInput::Text(s) => s.chars().count(),
FieldInput::Integer(n) => n.to_string().chars().count(),
FieldInput::Float(v) => format!("{}", v).chars().count(),
_ => 0,
}
}
fn edit_text(key: KeyEvent, buf: &mut String, cursor: &mut usize) -> bool {
let len_chars = buf.chars().count();
match key.code {
KeyCode::Char(c) => {
let byte = char_index_to_byte(buf, *cursor);
buf.insert(byte, c);
*cursor += 1;
true
}
KeyCode::Backspace if *cursor > 0 => {
let from = char_index_to_byte(buf, *cursor - 1);
let to = char_index_to_byte(buf, *cursor);
buf.replace_range(from..to, "");
*cursor -= 1;
true
}
KeyCode::Delete if *cursor < len_chars => {
let from = char_index_to_byte(buf, *cursor);
let to = char_index_to_byte(buf, *cursor + 1);
buf.replace_range(from..to, "");
true
}
KeyCode::Left if *cursor > 0 => { *cursor -= 1; true }
KeyCode::Right if *cursor < len_chars => { *cursor += 1; true }
KeyCode::Home if *cursor != 0 => { *cursor = 0; true }
KeyCode::End if *cursor != len_chars => { *cursor = len_chars; true }
_ => false,
}
}
fn edit_numeric(key: KeyEvent, buf: &mut String, cursor: &mut usize, integer: bool) -> bool {
match key.code {
KeyCode::Char(c) => {
let is_digit = c.is_ascii_digit();
let is_sign = c == '-' && *cursor == 0 && !buf.starts_with('-');
let is_dot = !integer && c == '.' && !buf.contains('.');
if is_digit || is_sign || is_dot {
let byte = char_index_to_byte(buf, *cursor);
buf.insert(byte, c);
*cursor += 1;
true
} else {
false
}
}
_ => edit_text(key, buf, cursor),
}
}
fn char_index_to_byte(s: &str, char_idx: usize) -> usize {
s.char_indices().nth(char_idx).map(|(b, _)| b).unwrap_or(s.len())
}
pub fn label_prefix(
label: &str,
pad: usize,
error: bool,
theme: &Theme,
) -> Vec<Span<'static>> {
let marker = if error {
Span::styled(
"*",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)
} else {
Span::raw(" ")
};
let total = pad + 2;
let filler_width = total.saturating_sub(label.chars().count() + 1);
vec![
Span::raw(" "),
Span::styled(label.to_string(), theme.hint),
marker,
Span::raw(" ".repeat(filler_width)),
]
}
fn split_at_char(s: &str, char_idx: usize) -> (&str, &str) {
for (i, (b, _)) in s.char_indices().enumerate() {
if i == char_idx {
return (&s[..b], &s[b..]);
}
}
(s, "")
}
pub fn input_row(
label: &str,
pad: usize,
value: &str,
active: bool,
error: bool,
caret: Option<usize>,
theme: &Theme,
) -> Line<'static> {
let mut spans = label_prefix(label, pad, error, theme);
if active {
spans.push(Span::styled("[", theme.shortcut_key));
match caret {
Some(pos) => {
let (lhs, rhs) = split_at_char(value, pos);
spans.push(Span::styled(lhs.to_string(), theme.body));
spans.push(Span::styled(
"│".to_string(),
theme.shortcut_key.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(rhs.to_string(), theme.body));
}
None => spans.push(Span::styled(value.to_string(), theme.body)),
}
spans.push(Span::styled("]", theme.shortcut_key));
} else {
spans.push(Span::styled(value.to_string(), theme.body));
}
Line::from(spans)
}
pub fn select_row(
label: &str,
pad: usize,
value: &str,
active: bool,
error: bool,
theme: &Theme,
) -> Line<'static> {
let mut spans = label_prefix(label, pad, error, theme);
if active {
spans.push(Span::styled("◀ ", theme.shortcut_key));
spans.push(Span::styled(value.to_string(), theme.body));
spans.push(Span::styled(" ▶", theme.shortcut_key));
} else {
spans.push(Span::styled(value.to_string(), theme.body));
}
Line::from(spans)
}
pub fn error_lines(msg: &str, indent: usize, max_width: usize) -> Vec<Line<'static>> {
let style = Style::default().fg(Color::Red).add_modifier(Modifier::ITALIC);
let prefix_cols = 2;
let avail = max_width.saturating_sub(indent + prefix_cols).max(1);
let chunks = wrap_chars(msg, avail);
chunks
.into_iter()
.enumerate()
.map(|(i, chunk)| {
let marker = if i == 0 { "└ " } else { " " };
Line::from(vec![
Span::raw(" ".repeat(indent)),
Span::styled(format!("{}{}", marker, chunk), style),
])
})
.collect()
}
pub fn wrap_chars(s: &str, width: usize) -> Vec<String> {
if width == 0 { return vec![s.to_string()]; }
let mut out: Vec<String> = Vec::new();
let mut line = String::new();
let mut line_len = 0usize;
for word in s.split_whitespace() {
let wlen = word.chars().count();
if wlen > width {
if !line.is_empty() {
out.push(std::mem::take(&mut line));
line_len = 0;
}
let mut buf = String::new();
let mut n = 0;
for c in word.chars() {
buf.push(c);
n += 1;
if n == width {
out.push(std::mem::take(&mut buf));
n = 0;
}
}
if !buf.is_empty() { line = buf; line_len = n; }
continue;
}
let sep = if line_len == 0 { 0 } else { 1 };
if line_len + sep + wlen > width {
out.push(std::mem::take(&mut line));
line_len = 0;
}
if line_len > 0 {
line.push(' ');
line_len += 1;
}
line.push_str(word);
line_len += wlen;
}
if !line.is_empty() { out.push(line); }
if out.is_empty() { out.push(String::new()); }
out
}
#[derive(Debug, Clone)]
pub struct FormStyle {
pub label_pad: usize,
pub show_caret: bool,
pub show_description: bool,
}
impl Default for FormStyle {
fn default() -> Self {
Self { label_pad: 0, show_caret: true, show_description: true }
}
}
pub fn render_form(f: &mut Frame, area: Rect, state: &FormState, theme: &Theme) {
render_form_with(f, area, state, &FormStyle::default(), &[], theme);
}
pub fn render_form_with(
f: &mut Frame,
area: Rect,
state: &FormState,
style: &FormStyle,
errors: &[(String, String)],
theme: &Theme,
) {
if area.height == 0 { return; }
let pad = if style.label_pad == 0 {
state
.fields
.iter()
.filter(|f| f.visible)
.map(|f| f.label.chars().count())
.max()
.unwrap_or(0)
} else {
style.label_pad
};
let mut lines: Vec<Line<'static>> = Vec::new();
for (idx, field) in state.fields.iter().enumerate() {
if !field.visible { continue; }
let active = idx == state.focused;
let touched = state.touched.contains(&field.id);
let has_error = touched
&& errors.iter().any(|(id, _)| id == &field.id);
let label_with_req = if field.required {
format!("{}*", field.label)
} else {
field.label.clone()
};
let caret = if style.show_caret && active {
Some(state.cursors.get(idx).copied().unwrap_or(0))
} else {
None
};
let line = match &field.input {
FieldInput::Text(s) => input_row(&label_with_req, pad, s, active, has_error, caret, theme),
FieldInput::Integer(n) => {
let s = n.to_string();
input_row(&label_with_req, pad, &s, active, has_error, caret, theme)
}
FieldInput::Float(v) => {
let s = format!("{}", v);
input_row(&label_with_req, pad, &s, active, has_error, caret, theme)
}
FieldInput::Boolean(b) => {
let v = if *b { "yes" } else { "no" };
select_row(&label_with_req, pad, v, active, has_error, theme)
}
FieldInput::Enum { options, selected } => {
let v = options.get(*selected).map(String::as_str).unwrap_or("");
select_row(&label_with_req, pad, v, active, has_error, theme)
}
FieldInput::ReadOnly(s) => {
let mut spans = label_prefix(&label_with_req, pad, has_error, theme);
spans.push(Span::styled(s.clone(), theme.hint));
Line::from(spans)
}
FieldInput::List(items) => {
let v = if items.is_empty() {
"(empty)".to_string()
} else {
items.join(", ")
};
let mut spans = label_prefix(&label_with_req, pad, has_error, theme);
spans.push(Span::styled(v, theme.body));
Line::from(spans)
}
};
lines.push(line);
if active && has_error {
if let Some((_, msg)) = errors.iter().find(|(id, _)| id == &field.id) {
let indent = 2;
lines.extend(error_lines(msg, indent, area.width as usize));
}
}
if style.show_description && active {
if let Some(desc) = &field.description {
lines.push(Line::from(vec![
Span::raw(" ".repeat(2 + pad + 2)),
Span::styled(desc.clone(), theme.hint),
]));
}
}
}
let paragraph = Paragraph::new(lines);
f.render_widget(paragraph, area);
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn text_state() -> FormState {
FormState::new(vec![
FormField::new("name", "name", FieldInput::Text("ab".into())),
FormField::new("age", "age", FieldInput::Integer(42)),
])
}
#[test]
fn text_insertion_tracks_cursor() {
let mut s = FormState::new(vec![FormField::new(
"x", "x", FieldInput::Text("".into()),
)]);
assert_eq!(s.cursors[0], 0);
let r = s.handle_key(key(KeyCode::Char('a')));
assert!(matches!(r, FormEvent::FieldChanged(_)));
assert_eq!(s.cursors[0], 1);
match &s.fields[0].input {
FieldInput::Text(v) => assert_eq!(v, "a"),
_ => unreachable!(),
}
}
#[test]
fn tab_moves_forward_and_touches_previous() {
let mut s = text_state();
assert_eq!(s.focused, 0);
let r = s.handle_key(key(KeyCode::Tab));
assert_eq!(r, FormEvent::FocusMoved);
assert_eq!(s.focused, 1);
assert!(s.touched.contains("name"));
}
#[test]
fn backtab_moves_back() {
let mut s = text_state();
s.focused = 1;
let r = s.handle_key(key(KeyCode::BackTab));
assert_eq!(r, FormEvent::FocusMoved);
assert_eq!(s.focused, 0);
}
#[test]
fn hidden_rows_skipped_by_navigation() {
let mut s = FormState::new(vec![
FormField::new("a", "a", FieldInput::Text("a".into())),
FormField::new("b", "b", FieldInput::Text("b".into())).visible(false),
FormField::new("c", "c", FieldInput::Text("c".into())),
]);
assert_eq!(s.focused, 0);
s.handle_key(key(KeyCode::Tab));
assert_eq!(s.focused, 2, "should skip the hidden row");
}
#[test]
fn enum_cycles_with_arrows() {
let mut s = FormState::new(vec![FormField::new(
"k",
"kind",
FieldInput::Enum {
options: vec!["A".into(), "B".into(), "C".into()],
selected: 0,
},
)]);
s.handle_key(key(KeyCode::Right));
match &s.fields[0].input {
FieldInput::Enum { selected, .. } => assert_eq!(*selected, 1),
_ => unreachable!(),
}
s.handle_key(key(KeyCode::Left));
s.handle_key(key(KeyCode::Left));
match &s.fields[0].input {
FieldInput::Enum { selected, .. } => assert_eq!(*selected, 2, "wraps"),
_ => unreachable!(),
}
}
#[test]
fn bool_toggles_with_left_right() {
let mut s = FormState::new(vec![FormField::new(
"b", "b", FieldInput::Boolean(false),
)]);
s.handle_key(key(KeyCode::Left));
assert!(matches!(s.fields[0].input, FieldInput::Boolean(true)));
}
#[test]
fn esc_cancels() {
let mut s = text_state();
assert_eq!(s.handle_key(key(KeyCode::Esc)), FormEvent::Cancel);
}
#[test]
fn enter_on_last_submits() {
let mut s = text_state();
s.focused = 1;
assert_eq!(s.handle_key(key(KeyCode::Enter)), FormEvent::Submit);
}
#[test]
fn enter_on_middle_advances() {
let mut s = text_state();
assert_eq!(s.handle_key(key(KeyCode::Enter)), FormEvent::FocusMoved);
assert_eq!(s.focused, 1);
}
#[test]
fn wrap_chars_respects_width_and_hard_breaks() {
let out = wrap_chars("one two three four", 8);
assert_eq!(out, vec!["one two", "three", "four"]);
let out = wrap_chars("abcdefghijk", 3);
assert_eq!(out, vec!["abc", "def", "ghi", "jk"]);
}
#[test]
fn error_lines_align_under_indent() {
let lines = error_lines("oops bad value", 4, 20);
assert!(!lines.is_empty());
let first = format!("{:?}", lines[0]);
assert!(first.contains("└"), "expected └ marker in first error line");
}
}