use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use rdom_core::{ListenerOptions, NodeId};
use crate::{TuiDom, TuiEvent};
pub fn install(dom: &mut TuiDom) {
use std::cell::RefCell;
use std::rc::Rc;
let root = dom.root();
let pending: Rc<RefCell<Vec<(NodeId, ToggleUndo)>>> = Rc::new(RefCell::new(Vec::new()));
dom.set_activation_hook(Some(Box::new(move |dom, target, event, phase| {
if event.event_type != "click" {
return;
}
let Some(widget) = closest_toggle(dom, target) else {
return;
};
match phase {
rdom_core::ActivationPhase::Pre => {
if dom.is_actually_disabled(widget) {
return;
}
let undo = pre_activate(dom, widget);
pending.borrow_mut().push((widget, undo));
}
rdom_core::ActivationPhase::Post { canceled } => {
let entry = {
let mut p = pending.borrow_mut();
p.iter()
.rposition(|(w, _)| *w == widget)
.map(|i| p.remove(i))
};
let Some((_, undo)) = entry else {
return; };
if canceled {
revert(dom, widget, undo);
} else if undo != ToggleUndo::Nothing {
fire_input_and_change(dom, widget);
}
}
}
})));
dom.add_event_listener(root, "keydown", ListenerOptions::default(), move |ctx| {
if ctx.event.default_prevented() {
return;
}
let Some(focused) = ctx.dom.focused() else {
return;
};
if !is_toggle(ctx.dom, focused) {
return;
}
let Some(key) = ctx.event.detail.as_keyboard() else {
return;
};
if key.modifiers.ctrl || key.modifiers.meta || key.modifiers.alt {
return;
}
if key.key != " " {
return;
}
let fake_mouse = MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: 0,
row: 0,
modifiers: KeyModifiers::empty(),
};
let mut click = TuiEvent::click(fake_mouse);
click.event = click.event.clone().with_synthetic(true);
crate::tui_event::dispatch_to_live(ctx.dom, focused, &mut click);
})
.expect("toggle space 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;
};
if !is_radio(ctx.dom, focused) {
return;
}
let Some(key) = ctx.event.detail.as_keyboard() else {
return;
};
if key.modifiers.ctrl || key.modifiers.shift || key.modifiers.alt || key.modifiers.meta {
return;
}
let direction = match key.key.as_str() {
"ArrowUp" | "ArrowLeft" => -1i32,
"ArrowDown" | "ArrowRight" => 1i32,
_ => return,
};
move_focus_within_group(ctx.dom, focused, direction);
})
.expect("toggle arrow listener install");
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ToggleUndo {
Checkbox,
Radio { previously_checked: Option<NodeId> },
Nothing,
}
fn pre_activate(dom: &mut TuiDom, widget: NodeId) -> ToggleUndo {
let was_checked = dom.node(widget).has_attribute("checked");
note_default_checked(dom, widget);
if is_radio(dom, widget) {
if was_checked {
return ToggleUndo::Nothing;
}
let siblings = dom.radio_group(widget);
for &sib in &siblings {
note_default_checked(dom, sib);
}
let previously_checked = siblings
.iter()
.copied()
.find(|&sib| sib != widget && dom.node(sib).has_attribute("checked"));
for sib in siblings {
if sib != widget {
let _ = dom.remove_attribute(sib, "checked");
}
}
let _ = dom.set_attribute(widget, "checked", "");
return ToggleUndo::Radio { previously_checked };
}
if was_checked {
let _ = dom.remove_attribute(widget, "checked");
} else {
let _ = dom.set_attribute(widget, "checked", "");
}
ToggleUndo::Checkbox
}
pub(crate) fn note_default_checked(dom: &mut TuiDom, widget: NodeId) {
let known = dom
.node(widget)
.ext()
.is_some_and(|e| e.default_checked.is_some());
if known {
return;
}
let checked = dom.node(widget).has_attribute("checked");
if let Some(ext) = dom.node_mut(widget).ext_mut() {
ext.default_checked = Some(checked);
}
}
pub(crate) fn reset_to_default(dom: &mut TuiDom, widget: NodeId) {
let Some(default) = dom.node(widget).ext().and_then(|e| e.default_checked) else {
return;
};
if default {
let _ = dom.set_attribute(widget, "checked", "");
} else {
let _ = dom.remove_attribute(widget, "checked");
}
}
fn revert(dom: &mut TuiDom, widget: NodeId, undo: ToggleUndo) {
match undo {
ToggleUndo::Nothing => {}
ToggleUndo::Checkbox => {
if dom.node(widget).has_attribute("checked") {
let _ = dom.remove_attribute(widget, "checked");
} else {
let _ = dom.set_attribute(widget, "checked", "");
}
}
ToggleUndo::Radio { previously_checked } => {
let _ = dom.remove_attribute(widget, "checked");
if let Some(prev) = previously_checked {
let _ = dom.set_attribute(prev, "checked", "");
}
}
}
}
fn fire_input_and_change(dom: &mut TuiDom, widget: NodeId) {
let mut input_ev = TuiEvent::new("input");
crate::tui_event::dispatch_to_live(dom, widget, &mut input_ev);
let mut change_ev = TuiEvent::new("change");
crate::tui_event::dispatch_to_live(dom, widget, &mut change_ev);
}
fn move_focus_within_group(dom: &mut TuiDom, focused: NodeId, direction: i32) {
let group = dom.radio_group(focused);
if group.len() < 2 {
return;
}
let Some(idx) = group.iter().position(|&id| id == focused) else {
return;
};
let len = group.len() as i32;
let next = ((idx as i32 + direction).rem_euclid(len)) as usize;
crate::runtime::focus::focus_node(dom, Some(group[next]));
}
fn closest_toggle(dom: &TuiDom, id: NodeId) -> Option<NodeId> {
let mut cur = Some(id);
while let Some(n) = cur {
if is_toggle(dom, n) {
return Some(n);
}
cur = dom.node(n).parent_node().map(|p| p.id());
}
None
}
pub(crate) fn is_toggle(dom: &TuiDom, id: NodeId) -> bool {
matches!(
dom.input_type_state(id),
Some(rdom_core::InputTypeState::Checkbox | rdom_core::InputTypeState::Radio)
)
}
fn is_radio(dom: &TuiDom, id: NodeId) -> bool {
dom.input_type_state(id) == Some(rdom_core::InputTypeState::Radio)
}
#[cfg(test)]
mod tests;