use std::time::Duration;
use compact_str::CompactString;
use crossterm::event::KeyCode;
use crate::events::HitMap;
use crate::layout::Rect;
use crate::layout::paint::{
range_is_vertical, range_spec, textarea_caret_position, textarea_char_at, textarea_scroll_row,
textarea_visual_lines, value_window,
};
use crate::tdom::node::{NodeArena, NodeId, NodeType};
use crate::tui::graph_api::char_width;
use super::InputState;
use super::cmd::RenderCmd;
use super::edit::{apply_input_key, char_to_byte};
#[derive(Debug)]
pub struct KeyGate {
pub id: u32,
pub cmds: Vec<RenderCmd>,
}
pub const KEY_GATE_TIMEOUT: Duration = Duration::from_millis(8);
pub fn apply_textarea_key(
value: &mut CompactString,
cursor: &mut usize,
content_width: usize,
key: KeyCode,
) -> bool {
match key {
KeyCode::Enter | KeyCode::Char('\n') | KeyCode::Char('\r') => {
let byte = char_to_byte(value, *cursor);
value.insert(byte, '\n');
*cursor = cursor.saturating_add(1);
true
}
KeyCode::Up => textarea_move_vertical(value, cursor, content_width, false),
KeyCode::Down => textarea_move_vertical(value, cursor, content_width, true),
_ => apply_input_key(value, cursor, key),
}
}
fn textarea_move_vertical(
value: &str,
cursor: &mut usize,
content_width: usize,
down: bool,
) -> bool {
let (row, col) = textarea_caret_position(value, content_width, *cursor);
let total = textarea_visual_lines(value, content_width).len();
if down {
if row.saturating_add(1) >= total {
return false;
}
} else if row == 0 {
return false;
}
let target_row = if down {
row.saturating_add(1)
} else {
row.saturating_sub(1)
};
let new_cursor = textarea_char_at(value, content_width, target_row, col);
if new_cursor == *cursor {
return false;
}
*cursor = new_cursor;
true
}
pub(super) fn textarea_content_width(arena: Option<&NodeArena>, node_id: NodeId) -> usize {
arena
.and_then(|a| a.get(node_id))
.and_then(|n| n.attributes.get("cols"))
.and_then(|v| v.parse().ok())
.unwrap_or(20)
.max(1)
}
pub(super) fn is_toggle_input(arena: &NodeArena, node_id: NodeId) -> bool {
arena.get(node_id).is_some_and(|n| {
n.get_type()
.is_some_and(|t| matches!(t.to_ascii_lowercase().as_str(), "checkbox" | "radio"))
})
}
pub(super) fn is_button_input(arena: &NodeArena, node_id: NodeId) -> bool {
arena.get(node_id).is_some_and(|n| {
n.node_type == NodeType::Input
&& n.get_type().is_some_and(|t| {
matches!(
t.to_ascii_lowercase().as_str(),
"button" | "submit" | "reset" | "image"
)
})
})
}
pub(super) fn is_number_input(arena: Option<&NodeArena>, node_id: NodeId) -> bool {
arena.is_some_and(|a| {
a.get(node_id).is_some_and(|n| {
n.get_type()
.is_some_and(|t| t.eq_ignore_ascii_case("number"))
})
})
}
pub(super) fn is_range_input(arena: &NodeArena, node_id: NodeId) -> bool {
arena.get(node_id).is_some_and(|n| {
n.get_type()
.is_some_and(|t| t.eq_ignore_ascii_case("range"))
})
}
pub(super) fn normalize_range_state(
state: &mut InputState,
arena: &NodeArena,
node_id: NodeId,
) -> bool {
let Some(node) = arena.get(node_id) else {
return false;
};
let spec = range_spec(node, Some(state.input_value.as_str()));
let value = spec.value_string();
let changed = state.input_value != value;
state.input_value = value;
state.input_cursor = state.input_value.chars().count();
changed
}
pub(super) fn apply_range_key(
state: &mut InputState,
arena: &NodeArena,
node_id: NodeId,
key: KeyCode,
) -> bool {
let Some(node) = arena.get(node_id) else {
return false;
};
let mut spec = range_spec(node, Some(state.input_value.as_str()));
let vertical = range_is_vertical(node);
let changed = match key {
KeyCode::Left => spec.advance(-1, false, false),
KeyCode::Down if vertical => spec.advance(1, false, false),
KeyCode::Down => spec.advance(-1, false, false),
KeyCode::Right => spec.advance(1, false, false),
KeyCode::Up if vertical => spec.advance(-1, false, false),
KeyCode::Up => spec.advance(1, false, false),
KeyCode::Home => spec.advance(0, true, false),
KeyCode::End => spec.advance(0, false, true),
_ => false,
};
if changed {
state.input_value = spec.value_string();
state.input_cursor = state.input_value.chars().count();
}
changed
}
pub(super) fn set_range_at_pointer(
state: &mut InputState,
arena: &NodeArena,
node_id: NodeId,
rect: crate::layout::Rect,
x: u32,
y: u32,
) -> bool {
let Some(node) = arena.get(node_id) else {
return false;
};
let spec = range_spec(node, Some(state.input_value.as_str()));
let value = if range_is_vertical(node) {
let local_y =
((y as usize).saturating_sub(rect.y)).min(rect.height.max(1).saturating_sub(1));
spec.value_at(local_y, rect.height.max(1))
} else {
let local_x =
((x as usize).saturating_sub(rect.x)).min(rect.width.max(1).saturating_sub(1));
spec.value_at(local_x, rect.width.max(1))
}
.value_string();
let changed = state.input_value != value;
state.input_value = value;
state.input_cursor = state.input_value.chars().count();
changed
}
pub fn number_input_allows_char(c: char) -> bool {
c.is_ascii_digit() || matches!(c, '-' | '+' | '.' | 'e' | 'E')
}
pub(super) fn is_readonly_input(arena: Option<&NodeArena>, node_id: NodeId) -> bool {
arena.is_some_and(|a| {
a.get(node_id).is_some_and(|n| {
n.node_type == NodeType::Input && n.attributes.contains_key("readonly")
})
})
}
pub(super) fn is_disabled_control(arena: Option<&NodeArena>, node_id: NodeId) -> bool {
arena.is_some_and(|a| {
a.get(node_id).is_some_and(|n| {
n.attributes.contains_key("disabled")
&& matches!(
n.node_type,
NodeType::Button | NodeType::Input | NodeType::Select | NodeType::Textarea
)
})
})
}
pub(super) fn focused_form(arena: Option<&NodeArena>, node_id: NodeId) -> Option<NodeId> {
let arena = arena?;
let mut current = arena.get(node_id).and_then(|n| n.parent);
while let Some(pid) = current {
if arena
.get(pid)
.is_some_and(|n| n.node_type == NodeType::Form)
{
return Some(pid);
}
current = arena.get(pid).and_then(|n| n.parent);
}
None
}
pub(super) fn is_textarea_node(arena: &NodeArena, node_id: NodeId) -> bool {
arena
.get(node_id)
.is_some_and(|n| n.node_type == NodeType::Textarea)
}
pub(super) fn textarea_index_at(state: &InputState, rect: Rect, x: u32, y: u32) -> usize {
let content_width = rect.width.max(1);
let rows = rect.height.max(1);
let scroll = textarea_scroll_row(&state.input_value, content_width, rows, state.input_cursor);
let local_row = (y as usize).saturating_sub(rect.y).saturating_add(scroll);
let local_col = (x as usize).saturating_sub(rect.x);
textarea_char_at(&state.input_value, content_width, local_row, local_col)
}
pub(super) fn is_text_field(arena: &NodeArena, node_id: NodeId) -> bool {
arena
.get(node_id)
.is_some_and(|n| matches!(n.node_type, NodeType::Input | NodeType::Textarea))
}
pub(super) fn blocks_page_selection(hit_map: &HitMap, arena: &NodeArena, node_id: NodeId) -> bool {
let mut current = Some(node_id);
while let Some(id) = current {
let Some(node) = arena.get(id) else {
return false;
};
if matches!(
node.node_type,
NodeType::Input
| NodeType::Textarea
| NodeType::Select
| NodeType::Button
| NodeType::Img
| NodeType::Progress
| NodeType::Meter
) {
return true;
}
current = node.parent;
}
hit_map.is_unselectable(node_id)
}
pub fn collect_input_ids(arena: &NodeArena) -> Vec<NodeId> {
let mut out = Vec::new();
let Some(root) = arena
.iter()
.find(|(_, n)| n.parent.is_none())
.map(|(id, _)| id)
else {
return out;
};
let mut stack = vec![root];
while let Some(id) = stack.pop() {
let node = &arena[id];
if node.attributes.contains_key("disabled")
&& matches!(
node.node_type,
NodeType::Button | NodeType::Input | NodeType::Select | NodeType::Textarea
)
{
continue;
}
match node.node_type {
NodeType::Textarea => out.push(id),
NodeType::Input => {
let t = node
.get_type()
.map(|t| t.to_ascii_lowercase())
.unwrap_or_default();
let is_button = matches!(t.as_str(), "button" | "submit" | "reset" | "image");
if t != "hidden" && t != "checkbox" && t != "radio" && !is_button {
out.push(id);
}
}
_ => {}
}
for child in node.children.iter().rev() {
stack.push(*child);
}
}
out
}
pub(super) fn char_index_at_x(value: &str, cursor: usize, inner_width: usize, x: usize) -> usize {
let (start, end) = value_window(value, cursor, inner_width);
let v: Vec<char> = value.chars().collect();
let mut col = 0usize;
for (i, &ch) in v.iter().enumerate().take(end).skip(start) {
let w = char_width(ch).max(1);
if x < col + w {
return i;
}
col += w;
}
if end < v.len() { end } else { v.len() }
}
pub(super) fn clear_text_selection(state: &mut InputState, cmds: &mut Vec<RenderCmd>) -> bool {
state.text_sel_armed = false;
let had_selection = state.text_sel_anchor.is_some() || state.text_sel_end.is_some();
if had_selection {
state.text_sel_anchor = None;
state.text_sel_end = None;
cmds.push(RenderCmd::TextSelection {
anchor: None,
end: None,
mode: state.text_sel_mode,
});
}
had_selection
}
#[cfg(test)]
mod range_key_tests {
use super::{InputState, apply_range_key};
use crate::tdom::node::{NodeArena, NodeType};
use compact_str::CompactString;
use crossterm::event::KeyCode;
#[test]
fn range_keys_step_and_clamp_without_text_editing() {
let mut arena = NodeArena::default();
let mut node = crate::tdom::node::Node::from(NodeType::Input);
node.attributes
.insert("type".to_string(), "range".to_string());
node.attributes.insert("min".to_string(), "0".to_string());
node.attributes.insert("max".to_string(), "2".to_string());
node.attributes.insert("step".to_string(), "1".to_string());
node.attributes.insert("value".to_string(), "1".to_string());
let id = arena.insert(node);
let mut state = InputState::new();
state.input_value = CompactString::from("1");
assert!(apply_range_key(&mut state, &arena, id, KeyCode::Left));
assert_eq!(state.input_value, "0");
assert!(!apply_range_key(&mut state, &arena, id, KeyCode::Left));
assert_eq!(state.input_value, "0");
assert!(apply_range_key(&mut state, &arena, id, KeyCode::Right));
assert_eq!(state.input_value, "1");
assert!(!apply_range_key(&mut state, &arena, id, KeyCode::Char('x')));
assert_eq!(state.input_value, "1");
}
#[test]
fn vertical_range_keys_follow_the_track() {
let mut arena = NodeArena::default();
let mut node = crate::tdom::node::Node::from(NodeType::Input);
node.attributes
.insert("type".to_string(), "range".to_string());
node.attributes
.insert("orient".to_string(), "vertical".to_string());
node.attributes.insert("min".to_string(), "0".to_string());
node.attributes.insert("max".to_string(), "2".to_string());
node.attributes.insert("value".to_string(), "1".to_string());
let id = arena.insert(node);
let mut state = InputState::new();
state.input_value = CompactString::from("1");
assert!(apply_range_key(&mut state, &arena, id, KeyCode::Up));
assert_eq!(state.input_value, "0");
assert!(apply_range_key(&mut state, &arena, id, KeyCode::Down));
assert_eq!(state.input_value, "1");
}
}