pub mod field;
pub use field::{FormField, FormFieldKind, FormValue};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
use super::{
Checkbox, CheckboxMessage, CheckboxState, Component, EventContext, InputField,
InputFieldMessage, InputFieldState, RenderContext, Select, SelectMessage, SelectState,
};
use crate::input::{Event, Key};
use crate::theme::Theme;
#[derive(Clone, Debug, PartialEq)]
enum FieldState {
Text(InputFieldState),
Checkbox(CheckboxState),
Select(SelectState),
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum FormMessage {
FocusNext,
FocusPrev,
Input(char),
Backspace,
Delete,
Left,
Right,
Home,
End,
Toggle,
SelectUp,
SelectDown,
SelectConfirm,
Submit,
Clear,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum FormOutput {
Submitted(Vec<(String, FormValue)>),
FieldChanged(String, FormValue),
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct FormState {
fields: Vec<FormField>,
#[cfg_attr(feature = "serialization", serde(skip))]
states: Vec<FieldState>,
focused_index: usize,
}
impl FormState {
pub fn new(fields: Vec<FormField>) -> Self {
let states: Vec<FieldState> = fields
.iter()
.map(|field| match &field.kind {
FormFieldKind::Text => FieldState::Text(InputFieldState::new()),
FormFieldKind::TextWithPlaceholder(p) => {
FieldState::Text(InputFieldState::with_placeholder(p))
}
FormFieldKind::Checkbox => FieldState::Checkbox(CheckboxState::new(&field.label)),
FormFieldKind::Select(options) => {
FieldState::Select(SelectState::new(options.clone()))
}
})
.collect();
Self {
fields,
states,
focused_index: 0,
}
}
pub fn field_count(&self) -> usize {
self.fields.len()
}
pub fn focused_field_id(&self) -> Option<&str> {
self.fields.get(self.focused_index).map(|f| f.id.as_str())
}
pub fn focused_field_index(&self) -> usize {
self.focused_index
}
pub fn value(&self, id: &str) -> Option<FormValue> {
self.fields
.iter()
.zip(self.states.iter())
.find(|(field, _)| field.id == id)
.map(|(_, state)| Self::extract_value(state))
}
pub fn values(&self) -> Vec<(String, FormValue)> {
self.fields
.iter()
.zip(self.states.iter())
.map(|(field, state)| (field.id.clone(), Self::extract_value(state)))
.collect()
}
pub fn fields(&self) -> &[FormField] {
&self.fields
}
pub fn field_label(&self, index: usize) -> Option<&str> {
self.fields.get(index).map(|f| f.label.as_str())
}
pub fn is_text_field(&self, index: usize) -> bool {
matches!(self.states.get(index), Some(FieldState::Text(_)))
}
pub fn is_checkbox_field(&self, index: usize) -> bool {
matches!(self.states.get(index), Some(FieldState::Checkbox(_)))
}
pub fn is_select_field(&self, index: usize) -> bool {
matches!(self.states.get(index), Some(FieldState::Select(_)))
}
pub fn update(&mut self, msg: FormMessage) -> Option<FormOutput> {
Form::update(self, msg)
}
fn extract_value(state: &FieldState) -> FormValue {
match state {
FieldState::Text(s) => FormValue::Text(s.value().to_string()),
FieldState::Checkbox(s) => FormValue::Bool(s.is_checked()),
FieldState::Select(s) => FormValue::Selected(s.selected_item().map(|v| v.to_string())),
}
}
fn focus_next(&mut self) {
if self.fields.is_empty() {
return;
}
self.focused_index = (self.focused_index + 1) % self.fields.len();
}
fn focus_prev(&mut self) {
if self.fields.is_empty() {
return;
}
self.focused_index = if self.focused_index == 0 {
self.fields.len() - 1
} else {
self.focused_index - 1
};
}
}
pub struct Form;
impl Component for Form {
type State = FormState;
type Message = FormMessage;
type Output = FormOutput;
fn init() -> Self::State {
FormState::default()
}
fn handle_event(
state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled || state.fields.is_empty() {
return None;
}
if let Some(key) = event.as_key() {
if key.code == Key::Tab && key.modifiers.shift() {
return Some(FormMessage::FocusPrev);
}
if key.code == Key::Tab {
return Some(FormMessage::FocusNext);
}
if key.code == Key::Enter && key.modifiers.ctrl() {
return Some(FormMessage::Submit);
}
match &state.states.get(state.focused_index)? {
FieldState::Text(_) => match key.code {
Key::Char(c) => Some(FormMessage::Input(c)),
Key::Backspace => Some(FormMessage::Backspace),
Key::Delete => Some(FormMessage::Delete),
Key::Left => Some(FormMessage::Left),
Key::Right => Some(FormMessage::Right),
Key::Home => Some(FormMessage::Home),
Key::End => Some(FormMessage::End),
_ => None,
},
FieldState::Checkbox(_) => match key.code {
Key::Char(' ') | Key::Enter => Some(FormMessage::Toggle),
_ => None,
},
FieldState::Select(s) => {
if s.is_open() {
match key.code {
Key::Up | Key::Char('k') => Some(FormMessage::SelectUp),
Key::Down | Key::Char('j') => Some(FormMessage::SelectDown),
Key::Enter => Some(FormMessage::SelectConfirm),
Key::Esc => Some(FormMessage::Toggle),
_ => None,
}
} else {
match key.code {
Key::Enter | Key::Char(' ') => Some(FormMessage::Toggle),
_ => None,
}
}
}
}
} else {
None
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
if state.fields.is_empty() {
return None;
}
match msg {
FormMessage::FocusNext => {
state.focus_next();
None
}
FormMessage::FocusPrev => {
state.focus_prev();
None
}
FormMessage::Submit => {
let values = state.values();
Some(FormOutput::Submitted(values))
}
FormMessage::Input(c) => {
if let Some(FieldState::Text(s)) = state.states.get_mut(state.focused_index) {
InputField::update(s, InputFieldMessage::Insert(c));
let id = state.fields[state.focused_index].id.clone();
let value = FormValue::Text(s.value().to_string());
Some(FormOutput::FieldChanged(id, value))
} else {
None
}
}
FormMessage::Backspace => {
if let Some(FieldState::Text(s)) = state.states.get_mut(state.focused_index) {
InputField::update(s, InputFieldMessage::Backspace);
let id = state.fields[state.focused_index].id.clone();
let value = FormValue::Text(s.value().to_string());
Some(FormOutput::FieldChanged(id, value))
} else {
None
}
}
FormMessage::Delete => {
if let Some(FieldState::Text(s)) = state.states.get_mut(state.focused_index) {
InputField::update(s, InputFieldMessage::Delete);
let id = state.fields[state.focused_index].id.clone();
let value = FormValue::Text(s.value().to_string());
Some(FormOutput::FieldChanged(id, value))
} else {
None
}
}
FormMessage::Left => {
if let Some(FieldState::Text(s)) = state.states.get_mut(state.focused_index) {
InputField::update(s, InputFieldMessage::Left);
}
None
}
FormMessage::Right => {
if let Some(FieldState::Text(s)) = state.states.get_mut(state.focused_index) {
InputField::update(s, InputFieldMessage::Right);
}
None
}
FormMessage::Home => {
if let Some(FieldState::Text(s)) = state.states.get_mut(state.focused_index) {
InputField::update(s, InputFieldMessage::Home);
}
None
}
FormMessage::End => {
if let Some(FieldState::Text(s)) = state.states.get_mut(state.focused_index) {
InputField::update(s, InputFieldMessage::End);
}
None
}
FormMessage::Clear => {
if let Some(FieldState::Text(s)) = state.states.get_mut(state.focused_index) {
InputField::update(s, InputFieldMessage::Clear);
let id = state.fields[state.focused_index].id.clone();
Some(FormOutput::FieldChanged(id, FormValue::Text(String::new())))
} else {
None
}
}
FormMessage::Toggle => {
let field_state = state.states.get_mut(state.focused_index)?;
let id = state.fields[state.focused_index].id.clone();
match field_state {
FieldState::Checkbox(s) => {
Checkbox::update(s, CheckboxMessage::Toggle);
Some(FormOutput::FieldChanged(
id,
FormValue::Bool(s.is_checked()),
))
}
FieldState::Select(s) => {
if s.is_open() {
Select::update(s, SelectMessage::Close);
} else {
Select::update(s, SelectMessage::Open);
}
None
}
_ => None,
}
}
FormMessage::SelectUp => {
if let Some(FieldState::Select(s)) = state.states.get_mut(state.focused_index) {
Select::update(s, SelectMessage::Up);
}
None
}
FormMessage::SelectDown => {
if let Some(FieldState::Select(s)) = state.states.get_mut(state.focused_index) {
Select::update(s, SelectMessage::Down);
}
None
}
FormMessage::SelectConfirm => {
if let Some(FieldState::Select(s)) = state.states.get_mut(state.focused_index) {
Select::update(s, SelectMessage::Confirm);
let id = state.fields[state.focused_index].id.clone();
let value = FormValue::Selected(s.selected_item().map(|v| v.to_string()));
Some(FormOutput::FieldChanged(id, value))
} else {
None
}
}
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
if state.fields.is_empty() {
return;
}
crate::annotation::with_registry(|reg| {
reg.open(
ctx.area,
crate::annotation::Annotation::new(crate::annotation::WidgetType::Form)
.with_id("form")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled),
);
});
let constraints: Vec<Constraint> = state
.fields
.iter()
.map(|f| match f.kind {
FormFieldKind::Checkbox => Constraint::Length(1),
_ => Constraint::Length(3),
})
.collect();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(ctx.area);
for (i, ((field, field_state), chunk)) in state
.fields
.iter()
.zip(state.states.iter())
.zip(chunks.iter())
.enumerate()
{
let is_field_focused = ctx.focused && i == state.focused_index;
match field_state {
FieldState::Text(s) => {
render_text_field(
ctx.frame,
*chunk,
field,
s,
is_field_focused,
ctx.disabled,
ctx.theme,
);
}
FieldState::Checkbox(s) => {
render_checkbox(
ctx.frame,
*chunk,
s,
is_field_focused,
ctx.disabled,
ctx.theme,
);
}
FieldState::Select(s) => {
render_select_field(
ctx.frame,
*chunk,
field,
s,
is_field_focused,
ctx.disabled,
ctx.theme,
);
}
}
}
crate::annotation::with_registry(|reg| {
reg.close();
});
}
}
fn render_text_field(
frame: &mut Frame,
area: Rect,
field: &FormField,
state: &InputFieldState,
is_focused: bool,
disabled: bool,
theme: &Theme,
) {
let border_style = if disabled {
theme.disabled_style()
} else if is_focused {
theme.focused_border_style()
} else {
theme.border_style()
};
let display_text = if state.value().is_empty() {
Span::styled(state.placeholder(), theme.disabled_style())
} else {
Span::styled(state.value(), theme.normal_style())
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(Span::styled(
format!(" {} ", field.label),
theme.normal_style(),
));
let widget = Paragraph::new(Line::from(display_text)).block(block);
frame.render_widget(widget, area);
if is_focused && !disabled {
let cursor_x = area.x + 1 + state.cursor_display_position() as u16;
let cursor_y = area.y + 1;
frame.set_cursor_position(Position::new(cursor_x, cursor_y));
}
}
fn render_checkbox(
frame: &mut Frame,
area: Rect,
state: &CheckboxState,
is_focused: bool,
disabled: bool,
theme: &Theme,
) {
let check = if state.is_checked() { "[x]" } else { "[ ]" };
let style = if disabled {
theme.disabled_style()
} else if is_focused {
theme.focused_style()
} else {
theme.normal_style()
};
let text = format!("{} {}", check, state.label());
let widget = Paragraph::new(Span::styled(text, style));
frame.render_widget(widget, area);
}
fn render_select_field(
frame: &mut Frame,
area: Rect,
field: &FormField,
state: &SelectState,
is_focused: bool,
disabled: bool,
theme: &Theme,
) {
let border_style = if disabled {
theme.disabled_style()
} else if is_focused {
theme.focused_border_style()
} else {
theme.border_style()
};
let display_text = match state.selected_item() {
Some(val) => Span::styled(val, theme.normal_style()),
None => Span::styled(state.placeholder(), theme.disabled_style()),
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(Span::styled(
format!(" {} ", field.label),
theme.normal_style(),
));
let widget = Paragraph::new(Line::from(display_text)).block(block);
frame.render_widget(widget, area);
}
#[cfg(test)]
mod snapshot_tests;
#[cfg(test)]
mod tests;