use rdom_core::{FormMethod, InputTypeState, ListenerOptions, NodeId};
use crate::{TuiDom, TuiEvent};
pub fn install(dom: &mut TuiDom) {
let root = dom.root();
dom.add_event_listener(root, "click", ListenerOptions::default(), move |ctx| {
if ctx.event.default_prevented() {
return;
}
let Some(target) = ctx.event.target else {
return;
};
let Some(button) = closest_form_button(ctx.dom, target) else {
return;
};
if ctx.dom.is_actually_disabled(button) {
return;
}
let Some(form) = ctx.dom.form_owner(button) else {
return;
};
match button_action(ctx.dom, button) {
ButtonAction::Submit => {
submit(ctx.dom, form, Some(button));
}
ButtonAction::Reset => {
if fire_reset(ctx.dom, form) {
reset_controls(ctx.dom, form);
}
}
ButtonAction::Button => {} }
})
.expect("form click listener install");
dom.add_event_listener(root, "keydown", ListenerOptions::default(), move |ctx| {
if ctx.event.default_prevented() {
return;
}
let Some(focused) = ctx.dom.focused() else {
return;
};
let Some(key) = ctx.event.detail.as_keyboard() else {
return;
};
let no_mods = !key.modifiers.ctrl
&& !key.modifiers.shift
&& !key.modifiers.alt
&& !key.modifiers.meta;
if !no_mods || key.key != "Enter" {
return;
}
if !crate::node::is_text_input(ctx.dom, focused) {
return;
}
let Some(form) = ctx.dom.form_owner(focused) else {
return;
};
if let Some(default) = default_button(ctx.dom, form) {
if !ctx.dom.is_actually_disabled(default) {
use crate::accessors::TuiAccessorsMut;
ctx.dom.node_mut(default).click();
}
return;
}
if count_text_inputs(ctx.dom, form) != 1 {
return;
}
submit(ctx.dom, form, None);
})
.expect("form implicit-enter submit listener install");
}
pub fn collect(dom: &TuiDom, form: NodeId) -> Vec<(String, String)> {
collect_with_submitter(dom, form, None)
}
pub fn collect_with_submitter(
dom: &TuiDom,
form: NodeId,
submitter: Option<NodeId>,
) -> Vec<(String, String)> {
let mut out = Vec::new();
for id in dom.form_listed_elements(form) {
collect_entry(dom, id, submitter, &mut out);
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ButtonAction {
Submit,
Reset,
Button,
}
fn button_action(dom: &TuiDom, id: NodeId) -> ButtonAction {
if dom.is_submit_button(id) {
return ButtonAction::Submit;
}
let reset = match dom.node(id).tag_name() {
Some("button") => dom
.node(id)
.get_attribute("type")
.is_some_and(|t| t.eq_ignore_ascii_case("reset")),
_ => dom.input_type_state(id) == Some(InputTypeState::Reset),
};
if reset {
ButtonAction::Reset
} else {
ButtonAction::Button
}
}
fn default_button(dom: &TuiDom, form: NodeId) -> Option<NodeId> {
dom.form_listed_elements(form)
.into_iter()
.find(|&id| dom.is_submit_button(id))
}
fn closest_form_button(dom: &TuiDom, id: NodeId) -> Option<NodeId> {
let mut cur = Some(id);
while let Some(n) = cur {
if super::button::is_button_like(dom, n) {
return Some(n);
}
cur = dom.node(n).parent_node().map(|p| p.id());
}
None
}
pub fn elements(dom: &TuiDom, form: NodeId) -> Vec<NodeId> {
dom.form_listed_elements(form)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SubmitOutcome {
Submitted,
Canceled,
Invalid,
NotAForm,
AlreadySubmitting,
Disconnected,
}
pub(crate) fn submit(dom: &mut TuiDom, form: NodeId, submitter: Option<NodeId>) -> SubmitOutcome {
if !dom.node(form).is_connected() {
return SubmitOutcome::Disconnected;
}
let (method, canceled) = {
let Some(firing) = FiringSubmissionEvents::enter(&mut *dom, form) else {
return SubmitOutcome::AlreadySubmitting;
};
let dom = &mut *firing.dom;
let detail = dom.submit_detail(form, submitter);
if !detail.no_validate
&& !crate::runtime::builtins::validation::interactively_validate(dom, form)
{
return SubmitOutcome::Invalid;
}
let method = detail.method;
let mut ev = TuiEvent::new("submit");
ev.event.detail = rdom_core::EventDetail::Submit(Box::new(detail));
if !crate::tui_event::dispatch_to_live(dom, form, &mut ev) {
return SubmitOutcome::Disconnected;
}
(method, ev.event.default_prevented())
};
if canceled {
return SubmitOutcome::Canceled;
}
if !dom.node(form).is_connected() {
return SubmitOutcome::Disconnected;
}
if method == FormMethod::Dialog
&& let Some(dialog) = crate::runtime::builtins::dialog::enclosing_dialog(dom, form)
{
let rv = submitter
.and_then(|b| dom.node(b).get_attribute("value"))
.unwrap_or("")
.to_string();
crate::runtime::builtins::dialog::close(dom, dialog, &rv);
}
SubmitOutcome::Submitted
}
struct FiringSubmissionEvents<'a> {
dom: &'a mut TuiDom,
form: NodeId,
}
impl<'a> FiringSubmissionEvents<'a> {
fn enter(dom: &'a mut TuiDom, form: NodeId) -> Option<Self> {
let already = {
let mut node = dom.node_mut(form);
let ext = node.ext_mut()?;
std::mem::replace(&mut ext.form_state.get_mut().firing_submission_events, true)
};
if already {
None
} else {
Some(Self { dom, form })
}
}
}
impl Drop for FiringSubmissionEvents<'_> {
fn drop(&mut self) {
if !self.dom.contains(self.form) {
return;
}
let mut node = self.dom.node_mut(self.form);
if let Some(state) = node.ext_mut().and_then(|e| e.form_state.existing_mut()) {
state.firing_submission_events = false;
}
}
}
pub(crate) fn request_submit(
dom: &mut TuiDom,
form: NodeId,
submitter: Option<NodeId>,
) -> rdom_core::Result<SubmitOutcome> {
if let Some(s) = submitter {
if !dom.is_submit_button(s) {
return Err(rdom_core::DomError::Type(
"requestSubmit: the submitter is not a submit button",
));
}
if dom.form_owner(s) != Some(form) {
return Err(rdom_core::DomError::NotFound);
}
}
Ok(submit(dom, form, submitter))
}
fn reset_controls(dom: &mut TuiDom, form: NodeId) {
for id in dom.form_listed_elements(form) {
match dom.node(id).tag_name() {
Some("input") if crate::runtime::builtins::toggle::is_toggle(dom, id) => {
crate::runtime::builtins::toggle::reset_to_default(dom, id);
}
Some("input") | Some("textarea") => {
crate::runtime::builtins::input::reset_to_default(dom, id);
}
Some("select") => {
crate::runtime::builtins::select::reset_to_default(dom, id);
}
_ => {}
}
}
}
fn fire_reset(dom: &mut TuiDom, form: NodeId) -> bool {
let mut ev = TuiEvent::new("reset");
crate::tui_event::dispatch_to_live(dom, form, &mut ev) && !ev.event.default_prevented()
}
fn count_text_inputs(dom: &TuiDom, form: NodeId) -> usize {
dom.form_listed_elements(form)
.into_iter()
.filter(|&id| crate::node::is_text_input(dom, id))
.count()
}
fn collect_entry(
dom: &TuiDom,
id: NodeId,
submitter: Option<NodeId>,
out: &mut Vec<(String, String)>,
) {
use InputTypeState as T;
let node = dom.node(id);
if dom.input_type_state(id) == Some(T::Image) {
if submitter == Some(id) && !dom.is_actually_disabled(id) {
let prefix = match node.get_attribute("name") {
Some(name) if !name.is_empty() => format!("{name}."),
_ => String::new(),
};
out.push((format!("{prefix}x"), "0".to_string()));
out.push((format!("{prefix}y"), "0".to_string()));
}
return;
}
if !dom.is_actually_disabled(id) {
let name = node.get_attribute("name").unwrap_or("").to_string();
if !name.is_empty() {
#[allow(clippy::collapsible_match, clippy::collapsible_if)]
match (node.tag_name(), dom.input_type_state(id)) {
(_, Some(T::Checkbox | T::Radio)) => {
if node.has_attribute("checked") {
let value = node.get_attribute("value").unwrap_or("on").to_string();
out.push((name, value));
}
}
_ if super::button::is_button_like(dom, id) => {
if submitter == Some(id) && button_action(dom, id) == ButtonAction::Submit {
let value = node.get_attribute("value").unwrap_or("").to_string();
out.push((name, value));
}
}
(_, Some(T::Hidden)) => {
if let Some(value) = node.get_attribute("value") {
out.push((name, value.to_string()));
}
}
(Some("input"), _) => {
out.push((name, crate::runtime::builtins::input::value(dom, id)));
}
(Some("textarea"), _) => {
out.push((name, crate::node::child_text(dom, id)));
}
(Some("select"), _) => {
use crate::runtime::builtins::select;
let selected = select::selected_options(dom, id);
for opt in selected
.into_iter()
.filter(|&o| !select::option_disabled(dom, o))
{
let value = crate::runtime::builtins::select::option_value(dom, opt);
out.push((name.clone(), value));
}
}
_ => {}
}
}
}
}
#[cfg(test)]
mod tests;