use std::collections::{HashMap, HashSet};
use crate::utils::html::escape;
pub trait Widget {
fn render(&self, name: &str, value: &str, attrs: &HashMap<String, String>) -> String;
fn input_type(&self) -> &str;
}
#[derive(Debug, Clone, Default)]
pub struct TextInput;
#[derive(Debug, Clone, Default)]
pub struct NumberInput;
#[derive(Debug, Clone, Default)]
pub struct EmailInput;
#[derive(Debug, Clone, Default)]
pub struct URLInput;
#[derive(Debug, Clone, Default)]
pub struct PasswordInput;
#[derive(Debug, Clone, Default)]
pub struct DateInput;
#[derive(Debug, Clone, Default)]
pub struct TimeInput;
#[derive(Debug, Clone, Default)]
pub struct DateTimeInput;
#[derive(Debug, Clone, Default)]
pub struct ColorInput;
#[derive(Debug, Clone, Default)]
pub struct RangeInput;
#[derive(Debug, Clone, Default)]
pub struct SearchInput;
#[derive(Debug, Clone, Default)]
pub struct TelInput;
#[derive(Debug, Clone, Default)]
pub struct Textarea;
#[derive(Debug, Clone, Default)]
pub struct CheckboxInput;
#[derive(Debug, Clone, Default)]
pub struct Select {
pub choices: Vec<(String, String)>,
}
#[derive(Debug, Clone, Default)]
pub struct SelectMultiple {
pub choices: Vec<(String, String)>,
}
#[derive(Debug, Clone, Default)]
pub struct RadioSelect {
pub choices: Vec<(String, String)>,
}
#[derive(Debug, Clone, Default)]
pub struct CheckboxSelectMultiple {
pub choices: Vec<(String, String)>,
}
#[derive(Debug, Clone, Default)]
pub struct HiddenInput;
#[derive(Debug, Clone, Default)]
pub struct FileInput;
#[derive(Debug, Clone, Default)]
pub struct ClearableFileInput {
pub initial_value: Option<String>,
}
#[derive(Default)]
pub struct MultiWidget {
pub widgets: Vec<Box<dyn Widget>>,
}
fn render_attrs(attrs: &HashMap<String, String>) -> String {
let mut rendered: Vec<_> = attrs.iter().collect();
rendered.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
rendered
.into_iter()
.map(|(key, value)| format!(" {}=\"{}\"", escape(key), escape(value)))
.collect()
}
fn render_input(
input_type: &str,
name: &str,
value: &str,
attrs: &HashMap<String, String>,
) -> String {
format!(
"<input type=\"{}\" name=\"{}\" value=\"{}\"{}>",
escape(input_type),
escape(name),
escape(value),
render_attrs(attrs)
)
}
fn render_input_without_value(
input_type: &str,
name: &str,
attrs: &HashMap<String, String>,
) -> String {
format!(
"<input type=\"{}\" name=\"{}\"{}>",
escape(input_type),
escape(name),
render_attrs(attrs)
)
}
fn parse_selected_values(value: &str) -> HashSet<&str> {
value
.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.collect()
}
fn render_options(choices: &[(String, String)], selected_values: &HashSet<&str>) -> String {
choices
.iter()
.map(|(option_value, option_label)| {
format!(
"<option value=\"{}\"{}>{}</option>",
escape(option_value),
if selected_values.contains(option_value.as_str()) {
" selected"
} else {
""
},
escape(option_label)
)
})
.collect()
}
fn render_select_widget(
name: &str,
value: &str,
attrs: &HashMap<String, String>,
choices: &[(String, String)],
multiple: bool,
) -> String {
let selected_values = parse_selected_values(value);
format!(
"<select name=\"{}\"{}{}>{}</select>",
escape(name),
if multiple { " multiple" } else { "" },
render_attrs(attrs),
render_options(choices, &selected_values)
)
}
fn render_choice_inputs(
input_type: &str,
name: &str,
value: &str,
attrs: &HashMap<String, String>,
choices: &[(String, String)],
allow_multiple: bool,
) -> String {
let selected_values = parse_selected_values(value);
let mut html = format!("<div{}>", render_attrs(attrs));
for (index, (choice_value, choice_label)) in choices.iter().enumerate() {
let is_selected = if allow_multiple {
selected_values.contains(choice_value.as_str())
} else {
choice_value == value
};
let id = format!("id_{name}_{index}");
html.push_str(&format!(
"<div><input type=\"{}\" name=\"{}\" value=\"{}\" id=\"{}\"{}><label for=\"{}\">{}</label></div>",
escape(input_type),
escape(name),
escape(choice_value),
escape(&id),
if is_selected { " checked" } else { "" },
escape(&id),
escape(choice_label)
));
}
html.push_str("</div>");
html
}
fn render_file_input(name: &str, attrs: &HashMap<String, String>) -> String {
render_input_without_value("file", name, attrs)
}
macro_rules! impl_simple_input_widget {
($widget:ident, $input_type:literal) => {
impl Widget for $widget {
fn render(&self, name: &str, value: &str, attrs: &HashMap<String, String>) -> String {
render_input(self.input_type(), name, value, attrs)
}
fn input_type(&self) -> &str {
$input_type
}
}
};
}
impl_simple_input_widget!(TextInput, "text");
impl_simple_input_widget!(NumberInput, "number");
impl_simple_input_widget!(EmailInput, "email");
impl_simple_input_widget!(URLInput, "url");
impl_simple_input_widget!(PasswordInput, "password");
impl_simple_input_widget!(DateInput, "date");
impl_simple_input_widget!(TimeInput, "time");
impl_simple_input_widget!(DateTimeInput, "datetime-local");
impl_simple_input_widget!(ColorInput, "color");
impl_simple_input_widget!(RangeInput, "range");
impl_simple_input_widget!(SearchInput, "search");
impl_simple_input_widget!(TelInput, "tel");
impl_simple_input_widget!(HiddenInput, "hidden");
impl Widget for Textarea {
fn render(&self, name: &str, value: &str, attrs: &HashMap<String, String>) -> String {
format!(
"<textarea name=\"{}\"{}>{}</textarea>",
escape(name),
render_attrs(attrs),
escape(value)
)
}
fn input_type(&self) -> &str {
"textarea"
}
}
impl Widget for CheckboxInput {
fn render(&self, name: &str, value: &str, attrs: &HashMap<String, String>) -> String {
let checked = matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
);
format!(
"<input type=\"{}\" name=\"{}\"{}{}>",
escape(self.input_type()),
escape(name),
if checked { " checked" } else { "" },
render_attrs(attrs)
)
}
fn input_type(&self) -> &str {
"checkbox"
}
}
impl Widget for Select {
fn render(&self, name: &str, value: &str, attrs: &HashMap<String, String>) -> String {
render_select_widget(name, value, attrs, &self.choices, false)
}
fn input_type(&self) -> &str {
"select"
}
}
impl Widget for SelectMultiple {
fn render(&self, name: &str, value: &str, attrs: &HashMap<String, String>) -> String {
render_select_widget(name, value, attrs, &self.choices, true)
}
fn input_type(&self) -> &str {
"select_multiple"
}
}
impl Widget for RadioSelect {
fn render(&self, name: &str, value: &str, attrs: &HashMap<String, String>) -> String {
render_choice_inputs("radio", name, value, attrs, &self.choices, false)
}
fn input_type(&self) -> &str {
"radio"
}
}
impl Widget for CheckboxSelectMultiple {
fn render(&self, name: &str, value: &str, attrs: &HashMap<String, String>) -> String {
render_choice_inputs("checkbox", name, value, attrs, &self.choices, true)
}
fn input_type(&self) -> &str {
"checkbox_select_multiple"
}
}
impl Widget for FileInput {
fn render(&self, name: &str, _value: &str, attrs: &HashMap<String, String>) -> String {
render_file_input(name, attrs)
}
fn input_type(&self) -> &str {
"file"
}
}
impl ClearableFileInput {
fn current_value<'a>(&'a self, value: &'a str) -> Option<&'a str> {
self.initial_value
.as_deref()
.filter(|current| !current.is_empty())
.or_else(|| (!value.is_empty()).then_some(value))
}
fn clear_checkbox_name(name: &str) -> String {
format!("{name}-clear")
}
}
impl Widget for ClearableFileInput {
fn render(&self, name: &str, value: &str, attrs: &HashMap<String, String>) -> String {
let file_input = render_file_input(name, attrs);
match self.current_value(value) {
Some(current_value) => format!(
"Currently: {} <label><input type=\"checkbox\" name=\"{}\"> Clear</label><br>{}",
escape(current_value),
escape(&Self::clear_checkbox_name(name)),
file_input
),
None => file_input,
}
}
fn input_type(&self) -> &str {
"file"
}
}
impl Widget for MultiWidget {
fn render(&self, name: &str, value: &str, attrs: &HashMap<String, String>) -> String {
let values: Vec<&str> = value.split('\t').collect();
let mut html = String::new();
for (index, widget) in self.widgets.iter().enumerate() {
let mut widget_attrs = attrs.clone();
if let Some(id) = attrs.get("id") {
widget_attrs.insert("id".to_string(), format!("{id}_{index}"));
}
html.push_str(&widget.render(
&format!("{name}_{index}"),
values.get(index).copied().unwrap_or(""),
&widget_attrs,
));
}
html
}
fn input_type(&self) -> &str {
"multi"
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn text_input_renders_escaped_attributes() {
let attrs = HashMap::from([
("class".to_string(), "input primary".to_string()),
("placeholder".to_string(), "Say <hello>".to_string()),
]);
let html = TextInput.render("username", "<admin>", &attrs);
assert!(html.contains("type=\"text\""));
assert!(html.contains("name=\"username\""));
assert!(html.contains("value=\"<admin>\""));
assert!(html.contains("placeholder=\"Say <hello>\""));
}
#[test]
fn checkbox_renders_checked_state_from_truthy_value() {
let html = CheckboxInput.render("tos", "true", &HashMap::new());
assert!(html.contains("type=\"checkbox\""));
assert!(html.contains("checked"));
}
#[test]
fn textarea_and_empty_select_render_expected_elements() {
let textarea = Textarea.render("bio", "Hello", &HashMap::new());
assert!(textarea.starts_with("<textarea"));
assert!(textarea.contains(">Hello</textarea>"));
let select = Select::default().render("status", "draft", &HashMap::new());
assert_eq!(select, "<select name=\"status\"></select>");
}
#[test]
fn date_input_renders_correctly() {
let html = DateInput.render("published_on", "2026-03-19", &HashMap::new());
assert_eq!(
html,
"<input type=\"date\" name=\"published_on\" value=\"2026-03-19\">"
);
}
#[test]
fn select_with_choices_renders_options() {
let select = Select {
choices: vec![
("draft".to_string(), "Draft".to_string()),
("published".to_string(), "Published <Live>".to_string()),
],
};
let html = select.render("status", "", &HashMap::new());
assert!(html.starts_with("<select"));
assert!(html.contains("<option value=\"draft\">Draft</option>"));
assert!(html.contains("<option value=\"published\">Published <Live></option>"));
}
#[test]
fn select_with_choices_marks_selected() {
let select = Select {
choices: vec![
("draft".to_string(), "Draft".to_string()),
("published".to_string(), "Published".to_string()),
],
};
let html = select.render("status", "published", &HashMap::new());
assert!(html.contains("<option value=\"published\" selected>Published</option>"));
assert!(html.contains("<option value=\"draft\">Draft</option>"));
}
#[test]
fn select_multiple_renders_with_multiple() {
let select = SelectMultiple {
choices: vec![
("rust".to_string(), "Rust".to_string()),
("python".to_string(), "Python".to_string()),
("go".to_string(), "Go".to_string()),
],
};
let html = select.render("languages", "rust, go", &HashMap::new());
assert!(html.starts_with("<select"));
assert!(html.contains(" multiple"));
assert!(html.contains("<option value=\"rust\" selected>Rust</option>"));
assert!(html.contains("<option value=\"python\">Python</option>"));
assert!(html.contains("<option value=\"go\" selected>Go</option>"));
}
#[test]
fn radio_select_renders_choices() {
let widget = RadioSelect {
choices: vec![
("draft".to_string(), "Draft".to_string()),
("published".to_string(), "Published".to_string()),
],
};
let html = widget.render("status", "published", &HashMap::new());
assert!(html.starts_with("<div>"));
assert!(html.contains(
"<div><input type=\"radio\" name=\"status\" value=\"draft\" id=\"id_status_0\"><label for=\"id_status_0\">Draft</label></div>"
));
assert!(html.contains(
"<div><input type=\"radio\" name=\"status\" value=\"published\" id=\"id_status_1\" checked><label for=\"id_status_1\">Published</label></div>"
));
}
#[test]
fn checkbox_select_multiple_renders() {
let widget = CheckboxSelectMultiple {
choices: vec![
("red".to_string(), "Red".to_string()),
("blue".to_string(), "Blue & Gold".to_string()),
],
};
let html = widget.render("colors", "blue", &HashMap::new());
assert!(html.starts_with("<div>"));
assert!(html.contains(
"<div><input type=\"checkbox\" name=\"colors\" value=\"blue\" id=\"id_colors_1\" checked><label for=\"id_colors_1\">Blue & Gold</label></div>"
));
}
#[test]
fn file_input_renders() {
let html = FileInput.render("attachment", "ignored.txt", &HashMap::new());
assert_eq!(html, "<input type=\"file\" name=\"attachment\">");
}
#[test]
fn clearable_file_input_renders_current_file_and_clear_checkbox() {
let widget = ClearableFileInput {
initial_value: Some("report.pdf".to_string()),
};
let html = widget.render("attachment", "", &HashMap::new());
assert!(html.contains("Currently: report.pdf"));
assert!(html.contains("name=\"attachment-clear\""));
assert!(html.ends_with("<input type=\"file\" name=\"attachment\">"));
}
#[test]
fn multi_widget_renders_sub_widgets() {
let widget = MultiWidget {
widgets: vec![Box::new(DateInput), Box::new(TimeInput)],
};
let attrs = HashMap::from([("id".to_string(), "scheduled".to_string())]);
let html = widget.render("scheduled_at", "2026-03-19\t09:30", &attrs);
assert!(html.contains(
"<input type=\"date\" name=\"scheduled_at_0\" value=\"2026-03-19\" id=\"scheduled_0\">"
));
assert!(html.contains(
"<input type=\"time\" name=\"scheduled_at_1\" value=\"09:30\" id=\"scheduled_1\">"
));
}
}