use rdom_core::{ListenerOptions, NodeId};
use crate::tui_event::TuiDispatchExt;
use crate::{TuiDom, TuiEvent};
const HIGHLIGHT_ATTR: &str = "data-rdom-highlight";
const ANCHOR_ATTR: &str = "data-rdom-anchor";
const OPEN_ATTR: &str = "data-rdom-open";
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 option = closest_option(ctx.dom, target);
let select = match option {
Some(o) => enclosing_select(ctx.dom, o),
None => closest_select(ctx.dom, target),
};
let Some(select) = select else {
return;
};
if ctx.dom.node(select).has_attribute("disabled") {
return;
}
let shift = ctx
.event
.detail
.as_mouse()
.map(|m| m.modifiers.shift)
.unwrap_or(false);
match option {
Some(opt) if !ctx.dom.node(opt).has_attribute("disabled") => {
click_option(ctx.dom, select, opt, shift);
if is_dropdown(ctx.dom, select) {
close(ctx.dom, select);
}
}
Some(_) => {
}
None => {
if is_dropdown(ctx.dom, select) {
if is_open(ctx.dom, select) {
close(ctx.dom, select);
} else {
open(ctx.dom, select);
}
}
}
}
})
.expect("select 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;
};
if ctx.dom.node(focused).tag_name() != Some("select") {
return;
}
if ctx.dom.node(focused).has_attribute("disabled") {
return;
}
let Some(key) = ctx.event.detail.as_keyboard() else {
return;
};
let select = focused;
let multi = is_multi(ctx.dom, select);
let shift = key.modifiers.shift;
let ctrl = key.modifiers.ctrl || key.modifiers.meta;
let alt = key.modifiers.alt;
let no_mods = !key.modifiers.ctrl && !shift && !alt && !key.modifiers.meta;
match key.key.as_str() {
"ArrowUp" | "ArrowDown" if !ctrl && !alt => {
let dir = if key.key == "ArrowDown" { 1 } else { -1 };
step_navigation(ctx.dom, select, dir, multi, shift);
}
"Home" => {
jump_to_end(ctx.dom, select, true, multi, shift);
}
"End" => {
jump_to_end(ctx.dom, select, false, multi, shift);
}
" " if multi && !ctrl => {
toggle_highlighted(ctx.dom, select);
}
"a" | "A" if multi && ctrl => {
select_all(ctx.dom, select);
}
"Escape" if no_mods && is_dropdown(ctx.dom, select) && is_open(ctx.dom, select) => {
close(ctx.dom, select);
}
"Enter" if no_mods && is_dropdown(ctx.dom, select) => {
if is_open(ctx.dom, select) {
close(ctx.dom, select);
} else {
open(ctx.dom, select);
}
}
other if !ctrl && !alt && other != " " => {
if let Some(c) = single_printable_char(other) {
typeahead_search(ctx.dom, select, c, multi);
}
}
_ => {}
}
})
.expect("select keydown listener install");
}
pub fn selected_options(dom: &TuiDom, select: NodeId) -> Vec<NodeId> {
let mut out = Vec::new();
for opt in options(dom, select) {
if dom.node(opt).has_attribute("selected") {
out.push(opt);
}
}
out
}
pub fn value(dom: &TuiDom, select: NodeId) -> String {
let selected = selected_options(dom, select);
if selected.is_empty() {
return String::new();
}
if is_multi(dom, select) {
selected
.iter()
.map(|&id| option_value(dom, id))
.collect::<Vec<_>>()
.join(" ")
} else {
option_value(dom, selected[0])
}
}
pub fn option_value(dom: &TuiDom, option: NodeId) -> String {
if let Some(v) = dom.node(option).get_attribute("value") {
return v.to_string();
}
option_label(dom, option)
}
pub fn option_label(dom: &TuiDom, option: NodeId) -> String {
if let Some(v) = dom.node(option).get_attribute("label") {
return v.to_string();
}
let mut text = String::new();
for child in dom.node(option).child_nodes() {
if child.node_type() == rdom_core::NodeType::Text
&& let Some(s) = child.node_value()
{
text.push_str(s);
}
}
text
}
fn click_option(dom: &mut TuiDom, select: NodeId, option: NodeId, shift: bool) {
let multi = is_multi(dom, select);
if multi {
if shift {
extend_selection_to(dom, select, option);
} else {
toggle_option(dom, select, option);
}
set_highlight(dom, select, Some(option));
} else {
select_single(dom, select, option);
set_highlight(dom, select, Some(option));
}
fire_input_and_change(dom, select);
}
fn select_single(dom: &mut TuiDom, select: NodeId, option: NodeId) {
let current = selected_options(dom, select);
let already = current.len() == 1 && current[0] == option;
if already {
return;
}
for opt in current {
let _ = dom.remove_attribute(opt, "selected");
}
let _ = dom.set_attribute(option, "selected", "");
}
fn toggle_option(dom: &mut TuiDom, _select: NodeId, option: NodeId) {
if dom.node(option).has_attribute("selected") {
let _ = dom.remove_attribute(option, "selected");
} else {
let _ = dom.set_attribute(option, "selected", "");
}
}
fn extend_selection_to(dom: &mut TuiDom, select: NodeId, target: NodeId) {
let anchor = anchor(dom, select).unwrap_or_else(|| {
let a = highlight(dom, select).unwrap_or(target);
set_anchor(dom, select, Some(a));
a
});
let all = options(dom, select);
let a_idx = all.iter().position(|&o| o == anchor);
let t_idx = all.iter().position(|&o| o == target);
let (Some(a_idx), Some(t_idx)) = (a_idx, t_idx) else {
return;
};
let (lo, hi) = if a_idx <= t_idx {
(a_idx, t_idx)
} else {
(t_idx, a_idx)
};
for (i, &opt) in all.iter().enumerate() {
if dom.node(opt).has_attribute("disabled") {
continue;
}
if i >= lo && i <= hi {
let _ = dom.set_attribute(opt, "selected", "");
} else {
let _ = dom.remove_attribute(opt, "selected");
}
}
}
fn step_navigation(dom: &mut TuiDom, select: NodeId, dir: i32, multi: bool, shift: bool) {
let all = options(dom, select);
if all.is_empty() {
return;
}
let current = highlight(dom, select).or_else(|| {
selected_options(dom, select).first().copied().or_else(|| {
all.iter()
.find(|&&o| !dom.node(o).has_attribute("disabled"))
.copied()
})
});
let next = match current {
Some(c) => step_from(dom, &all, c, dir),
None => return,
};
let Some(next) = next else { return };
if multi {
if shift {
if anchor(dom, select).is_none()
&& let Some(old) = current
{
set_anchor(dom, select, Some(old));
}
set_highlight(dom, select, Some(next));
extend_selection_to(dom, select, next);
} else {
set_highlight(dom, select, Some(next));
set_anchor(dom, select, None);
}
} else {
set_highlight(dom, select, Some(next));
select_single(dom, select, next);
}
fire_input_and_change(dom, select);
}
fn step_from(dom: &TuiDom, list: &[NodeId], from: NodeId, dir: i32) -> Option<NodeId> {
let start = list.iter().position(|&o| o == from)?;
let mut i = start as i32;
let len = list.len() as i32;
loop {
i += dir;
if i < 0 || i >= len {
return None;
}
let candidate = list[i as usize];
if !dom.node(candidate).has_attribute("disabled") {
return Some(candidate);
}
}
}
fn jump_to_end(dom: &mut TuiDom, select: NodeId, home: bool, multi: bool, shift: bool) {
let all = options(dom, select);
let target = if home {
all.iter()
.find(|&&o| !dom.node(o).has_attribute("disabled"))
.copied()
} else {
all.iter()
.rev()
.find(|&&o| !dom.node(o).has_attribute("disabled"))
.copied()
};
let Some(target) = target else { return };
set_highlight(dom, select, Some(target));
if multi {
if shift {
extend_selection_to(dom, select, target);
} else {
set_anchor(dom, select, None);
}
} else {
select_single(dom, select, target);
}
fire_input_and_change(dom, select);
}
fn toggle_highlighted(dom: &mut TuiDom, select: NodeId) {
let Some(h) = highlight(dom, select) else {
return;
};
if dom.node(h).has_attribute("disabled") {
return;
}
toggle_option(dom, select, h);
fire_input_and_change(dom, select);
}
fn select_all(dom: &mut TuiDom, select: NodeId) {
for opt in options(dom, select) {
if !dom.node(opt).has_attribute("disabled") {
let _ = dom.set_attribute(opt, "selected", "");
}
}
fire_input_and_change(dom, select);
}
use std::cell::RefCell;
use std::time::{Duration, Instant};
const TYPEAHEAD_TIMEOUT: Duration = Duration::from_millis(500);
thread_local! {
static TYPEAHEAD_STATE: RefCell<TypeaheadState> =
RefCell::new(TypeaheadState::default());
}
#[derive(Default)]
struct TypeaheadState {
buffer: String,
last: Option<Instant>,
last_select: Option<NodeId>,
}
fn single_printable_char(key: &str) -> Option<char> {
let mut chars = key.chars();
let first = chars.next()?;
if chars.next().is_some() {
return None;
}
if first.is_control() {
return None;
}
Some(first)
}
fn typeahead_search(dom: &mut TuiDom, select: NodeId, ch: char, multi: bool) {
let all = options(dom, select);
if all.is_empty() {
return;
}
let (query_lower, cycle_mode): (String, bool) = TYPEAHEAD_STATE.with(|s| {
let mut st = s.borrow_mut();
let now = Instant::now();
let expired = st
.last
.is_none_or(|t| now.duration_since(t) > TYPEAHEAD_TIMEOUT);
let switched = st.last_select != Some(select);
let lc = ch.to_ascii_lowercase();
let mut cycle = false;
if expired || switched {
st.buffer.clear();
st.buffer.push(lc);
} else if st.buffer.len() == 1 && st.buffer.starts_with(lc) {
cycle = true;
} else {
st.buffer.push(lc);
}
st.last = Some(now);
st.last_select = Some(select);
(st.buffer.clone(), cycle)
});
let start_idx = highlight(dom, select)
.and_then(|h| all.iter().position(|&o| o == h))
.map(|i| if cycle_mode { i + 1 } else { i })
.unwrap_or(0);
let len = all.len();
let target = (0..len).find_map(|offset| {
let i = (start_idx + offset) % len;
let opt = all[i];
if dom.node(opt).has_attribute("disabled") {
return None;
}
let label = option_label(dom, opt).to_ascii_lowercase();
label.starts_with(&query_lower).then_some(opt)
});
let Some(target) = target else { return };
set_highlight(dom, select, Some(target));
if !multi {
select_single(dom, select, target);
} else {
set_anchor(dom, select, None);
}
fire_input_and_change(dom, select);
}
#[cfg(test)]
pub(super) fn reset_typeahead_buffer_for_tests() {
TYPEAHEAD_STATE.with(|s| {
let mut st = s.borrow_mut();
st.buffer.clear();
st.last = None;
st.last_select = None;
});
}
fn fire_input_and_change(dom: &mut TuiDom, select: NodeId) {
let mut input_ev = TuiEvent::new("input");
let _ = dom.dispatch_tui_event(select, &mut input_ev);
let mut change_ev = TuiEvent::new("change");
let _ = dom.dispatch_tui_event(select, &mut change_ev);
}
fn is_multi(dom: &TuiDom, select: NodeId) -> bool {
dom.node(select).has_attribute("multiple")
}
pub fn options(dom: &TuiDom, select: NodeId) -> Vec<NodeId> {
let mut out = Vec::new();
walk_options(dom, select, &mut out);
out
}
fn walk_options(dom: &TuiDom, id: NodeId, out: &mut Vec<NodeId>) {
for child in dom.node(id).child_nodes() {
match child.tag_name() {
Some("option") => out.push(child.id()),
Some("optgroup") => walk_options(dom, child.id(), out),
_ => {}
}
}
}
fn closest_option(dom: &TuiDom, id: NodeId) -> Option<NodeId> {
let mut cur = Some(id);
while let Some(n) = cur {
if dom.node(n).tag_name() == Some("option") {
return Some(n);
}
cur = dom.node(n).parent_node().map(|p| p.id());
}
None
}
fn closest_select(dom: &TuiDom, id: NodeId) -> Option<NodeId> {
enclosing_select(dom, id)
}
fn enclosing_select(dom: &TuiDom, id: NodeId) -> Option<NodeId> {
let mut cur = Some(id);
while let Some(n) = cur {
if dom.node(n).tag_name() == Some("select") {
return Some(n);
}
cur = dom.node(n).parent_node().map(|p| p.id());
}
None
}
pub fn is_dropdown(dom: &TuiDom, select: NodeId) -> bool {
!dom.node(select).has_attribute("multiple") && !dom.node(select).has_attribute("size")
}
pub fn is_open(dom: &TuiDom, select: NodeId) -> bool {
dom.node(select).has_attribute(OPEN_ATTR)
}
pub fn open(dom: &mut TuiDom, select: NodeId) {
if !is_dropdown(dom, select) {
return;
}
let _ = dom.set_attribute(select, OPEN_ATTR, "");
}
pub fn close(dom: &mut TuiDom, select: NodeId) {
if !is_dropdown(dom, select) {
return;
}
let _ = dom.remove_attribute(select, OPEN_ATTR);
}
fn highlight(dom: &TuiDom, select: NodeId) -> Option<NodeId> {
options(dom, select)
.into_iter()
.find(|&o| dom.node(o).has_attribute(HIGHLIGHT_ATTR))
}
fn set_highlight(dom: &mut TuiDom, select: NodeId, target: Option<NodeId>) {
for opt in options(dom, select) {
let want = target == Some(opt);
let has = dom.node(opt).has_attribute(HIGHLIGHT_ATTR);
if want && !has {
let _ = dom.set_attribute(opt, HIGHLIGHT_ATTR, "");
} else if !want && has {
let _ = dom.remove_attribute(opt, HIGHLIGHT_ATTR);
}
}
}
fn anchor(dom: &TuiDom, select: NodeId) -> Option<NodeId> {
options(dom, select)
.into_iter()
.find(|&o| dom.node(o).has_attribute(ANCHOR_ATTR))
}
fn set_anchor(dom: &mut TuiDom, select: NodeId, target: Option<NodeId>) {
for opt in options(dom, select) {
let want = target == Some(opt);
let has = dom.node(opt).has_attribute(ANCHOR_ATTR);
if want && !has {
let _ = dom.set_attribute(opt, ANCHOR_ATTR, "");
} else if !want && has {
let _ = dom.remove_attribute(opt, ANCHOR_ATTR);
}
}
}
#[cfg(test)]
mod tests;