use crossterm::event::MouseButton;
use crate::events::HitMap;
use crate::script::ScriptMsg;
use crate::tdom::node::{NodeArena, NodeId, NodeType};
use super::DBLCLICK_MS;
use super::InputState;
use super::TEXT_SEL_DRAG_THRESHOLD;
use super::cmd::{RenderCmd, SelectionMode};
use super::field::{
blocks_page_selection, char_index_at_x, clear_text_selection, is_button_input,
is_disabled_control, is_range_input, is_text_field, is_textarea_node, is_toggle_input,
set_range_at_pointer, textarea_index_at,
};
use super::focus::{blur_input, focus_input, push_input_render};
use super::scroll_anim::{ScrollTarget, active_scroll_target, step_target};
use super::select::{close_select, hit_select_option, open_select, refresh_datalist, row_option};
pub fn handle_click(
state: &mut InputState,
hit_map: &HitMap,
arena: &NodeArena,
x: u32,
y: u32,
msgs: &mut Vec<ScriptMsg>,
cmds: &mut Vec<RenderCmd>,
) {
let clicked = hit_map.hit_test(x, y);
if clicked.is_some_and(|r| is_disabled_control(Some(arena), r.node_id)) {
return;
}
msgs.push(ScriptMsg::MouseDown { x, y });
msgs.push(ScriptMsg::Click { x, y });
let now = std::time::Instant::now();
if state.last_click.is_some_and(|(at, px, py)| {
now.duration_since(at).as_millis() <= DBLCLICK_MS && (px, py) == (x, y)
}) {
state.last_click = None;
msgs.push(ScriptMsg::DblClick { x, y });
} else {
state.last_click = Some((now, x, y));
}
let click_node = resolve_click_node(arena, clicked.map(|r| r.node_id));
let clicked_input = click_node.is_some_and(|id| is_text_field(arena, id));
if clicked_input {
if let Some(node_id) = click_node {
if is_toggle_input(arena, node_id) {
if state.focused_input.is_some() {
blur_input(state, msgs, cmds);
}
msgs.push(ScriptMsg::ToggleCheck { node_id });
} else if is_button_input(arena, node_id) {
if state.focused_input.is_some() {
blur_input(state, msgs, cmds);
}
} else if is_range_input(arena, node_id) {
focus_input(state, node_id, arena, msgs, cmds);
if let Some(region) = clicked
&& set_range_at_pointer(state, arena, node_id, region.rect, x, y)
{
push_input_render(state, node_id, cmds);
msgs.push(ScriptMsg::RangeInput {
node_id,
value: state.input_value.clone(),
});
}
} else {
focus_input(state, node_id, arena, msgs, cmds);
refresh_datalist(state, Some(hit_map), Some(arena), cmds);
}
}
} else if state.focused_input.is_some() {
blur_input(state, msgs, cmds);
}
}
pub(super) fn scroll_at_wheel(
state: &mut InputState,
hit_map: &HitMap,
arena: &NodeArena,
x: u32,
y: u32,
up: bool,
) -> Option<RenderCmd> {
let hit = hit_map.hit_test(x, y)?;
let delta = if up {
-(super::SCROLL_LINES_PER_TICK as i32)
} else {
super::SCROLL_LINES_PER_TICK as i32
};
let mut current = Some(hit.node_id);
while let Some(node_id) = current {
current = arena.get(node_id).and_then(|node| node.parent);
let Some(scroll) = hit_map.scrollbars.get(&node_id) else {
continue;
};
let at_edge = if up {
state
.scroll_offsets
.get(&scroll.node_id)
.copied()
.unwrap_or(scroll.scroll_y)
== 0
} else {
state
.scroll_offsets
.get(&scroll.node_id)
.copied()
.unwrap_or(scroll.scroll_y)
>= scroll.max_scroll
};
if at_edge {
continue;
}
let base = state
.scroll_offsets
.get(&scroll.node_id)
.copied()
.unwrap_or(scroll.scroll_y);
if state.smooth_scroll {
let base = active_scroll_target(state, ScrollTarget::Container(scroll.node_id))
.unwrap_or(base);
return step_target(base, delta, 0, scroll.max_scroll).map(|to| {
state.show_scrollbar(scroll.node_id);
RenderCmd::ScrollAnimate {
target: ScrollTarget::Container(scroll.node_id),
to,
}
});
}
let next = step_target(base, delta, 0, scroll.max_scroll)?;
state.show_scrollbar(scroll.node_id);
state.scroll_offsets.insert(scroll.node_id, next);
return Some(RenderCmd::RenderScroll {
node_id: scroll.node_id,
scroll_y: next,
});
}
None
}
pub(super) fn scroll_at_wheel_x(
state: &mut InputState,
hit_map: &HitMap,
arena: &NodeArena,
x: u32,
y: u32,
left: bool,
) -> Option<RenderCmd> {
let hit = hit_map.hit_test(x, y)?;
let delta = if left {
-(super::SCROLL_LINES_PER_TICK as i32)
} else {
super::SCROLL_LINES_PER_TICK as i32
};
let mut current = Some(hit.node_id);
while let Some(node_id) = current {
current = arena.get(node_id).and_then(|node| node.parent);
let Some(scroll) = hit_map.scrollbars.get(&node_id) else {
continue;
};
if scroll.max_scroll_x == 0 {
continue;
}
let at_edge = if left {
state
.scroll_x_offsets
.get(&scroll.node_id)
.copied()
.unwrap_or(scroll.scroll_x)
== 0
} else {
state
.scroll_x_offsets
.get(&scroll.node_id)
.copied()
.unwrap_or(scroll.scroll_x)
>= scroll.max_scroll_x
};
if at_edge {
continue;
}
let base = state
.scroll_x_offsets
.get(&scroll.node_id)
.copied()
.unwrap_or(scroll.scroll_x);
if let Some(next) = step_target(base, delta, 0, scroll.max_scroll_x) {
state.show_scrollbar(scroll.node_id);
state.scroll_x_offsets.insert(scroll.node_id, next);
return Some(RenderCmd::RenderScrollX {
node_id: scroll.node_id,
scroll_x: next,
});
}
}
None
}
pub(super) fn reveal_scrollbar_at(
state: &mut InputState,
hit_map: &HitMap,
x: u32,
y: u32,
cmds: &mut Vec<RenderCmd>,
) {
if let Some(scroll) = hit_map
.scrollbars
.values()
.find(|scroll| scroll.track.contains(x, y))
{
state.show_scrollbar(scroll.node_id);
let offset = state
.scroll_offsets
.get(&scroll.node_id)
.copied()
.unwrap_or(scroll.scroll_y);
cmds.push(RenderCmd::RenderScroll {
node_id: scroll.node_id,
scroll_y: offset,
});
}
}
pub fn handle_mouse_up(
state: &mut InputState,
hit_map: &HitMap,
arena: &NodeArena,
release_screen: (u16, u16),
button: MouseButton,
msgs: &mut Vec<ScriptMsg>,
cmds: &mut Vec<RenderCmd>,
) {
let range_drag = state.drag_selecting && state.field_drag_started && state.focused_is_range;
let scroll_drag = state.scroll_drag.take();
state.scroll_drag_x = None;
let pressed = state.pressed.take();
let press_target = state.pressed_target.take();
let press_origin = state.press_screen.take();
state.drag_origin = None;
state.drag_selecting = false;
let field_drag_started = state.field_drag_started;
state.field_drag_started = false;
let page_selection_drag = state.text_sel_anchor.is_some() || state.text_sel_end.is_some();
let (x, y) = canvas_pos(state, release_screen.0, release_screen.1);
msgs.push(ScriptMsg::Move { x, y });
msgs.push(ScriptMsg::MouseUp { x, y });
if button != MouseButton::Left {
if page_selection_drag {
msgs.push(ScriptMsg::SelectionChange);
} else if button == MouseButton::Right {
msgs.push(ScriptMsg::ContextMenu { x, y });
}
return;
}
if scroll_drag.is_some() {
return;
}
if pressed.is_none() {
return;
}
if range_drag
&& let Some(focused) = state.focused_input
&& let Some(region) = hit_map.region_for(focused)
&& set_range_at_pointer(state, arena, focused, region.rect, x, y)
{
push_input_render(state, focused, cmds);
msgs.push(ScriptMsg::RangeInput {
node_id: focused,
value: state.input_value.clone(),
});
}
if let Some(open) = state.open_select.clone()
&& let Some(row) = hit_select_option(&open, x, y)
&& let Some(option_idx) = row_option(&open.rows, row)
{
close_select(state, arena, Some(option_idx), msgs, cmds);
return;
}
let within_click_threshold = press_origin.is_some_and(|(px, py)| {
u32::from(
release_screen
.0
.abs_diff(px)
.max(release_screen.1.abs_diff(py)),
) < TEXT_SEL_DRAG_THRESHOLD
});
if within_click_threshold && press_target.is_some() {
handle_click(state, hit_map, arena, x, y, msgs, cmds);
}
if field_drag_started || page_selection_drag {
msgs.push(ScriptMsg::SelectionChange);
}
}
fn resolve_click_node(arena: &NodeArena, clicked: Option<NodeId>) -> Option<NodeId> {
let id = clicked?;
if arena
.get(id)
.is_some_and(|n| n.node_type == NodeType::Label)
{
Some(label_control(arena, id).unwrap_or(id))
} else {
Some(id)
}
}
fn find_body(arena: &NodeArena) -> Option<NodeId> {
arena
.iter()
.find(|(_, node)| node.node_type == NodeType::Body)
.map(|(id, _)| id)
.or_else(|| {
arena
.iter()
.find(|(_, node)| node.parent.is_none())
.map(|(id, _)| id)
})
}
fn label_control(arena: &NodeArena, label_id: NodeId) -> Option<NodeId> {
let label = &arena[label_id];
if let Some(for_id) = label.attributes.get("for") {
return arena.iter().find_map(|(id, n)| {
if n.get_id().is_some_and(|i| i == for_id) && is_labelable(&n.node_type) {
Some(id)
} else {
None
}
});
}
let mut stack: Vec<NodeId> = label.children.iter().rev().copied().collect();
while let Some(id) = stack.pop() {
let node = &arena[id];
if is_labelable(&node.node_type) {
return Some(id);
}
stack.extend(node.children.iter().rev().copied());
}
None
}
fn is_labelable(node_type: &NodeType) -> bool {
matches!(
node_type,
NodeType::Input
| NodeType::Select
| NodeType::Textarea
| NodeType::Button
| NodeType::Output
| NodeType::Progress
| NodeType::Meter
)
}
pub fn handle_mouse_down(
state: &mut InputState,
hit_map: &HitMap,
arena: &NodeArena,
x: u32,
y: u32,
button: MouseButton,
msgs: &mut Vec<ScriptMsg>,
cmds: &mut Vec<RenderCmd>,
) {
let clicked = hit_map.hit_test(x, y);
state.press_screen = Some((
x.min(u16::MAX as u32) as u16,
y.saturating_sub(state.scroll_y).min(u16::MAX as u32) as u16,
));
state.drag_origin = Some((x, y));
state.pressed_target = None;
state.field_drag_started = false;
if button == MouseButton::Left {
let h_hit = hit_map
.scrollbars
.values()
.find(|scroll| scroll.h_track.height > 0 && scroll.h_track.contains(x, y))
.map(|scroll| {
let grab_offset = x
.saturating_sub(scroll.h_thumb.x as u32)
.min(scroll.h_thumb.width as u32);
(
scroll.node_id,
grab_offset,
scroll.offset_at_x(x, grab_offset),
)
});
if let Some((node_id, grab_offset, offset)) = h_hit {
state.scroll_anim = None;
state.show_scrollbar(node_id);
state.scroll_drag = Some((node_id, grab_offset));
state.scroll_drag_x = Some(grab_offset);
state.scroll_x_offsets.insert(node_id, offset);
cmds.push(RenderCmd::RenderScrollX {
node_id,
scroll_x: offset,
});
return;
}
let scrollbar_hit = hit_map
.scrollbars
.values()
.find(|scroll| scroll.track.contains(x, y))
.map(|scroll| {
let grab_offset = y
.saturating_sub(scroll.thumb.y as u32)
.min(scroll.thumb.height as u32);
let offset = scroll.offset_at(y, grab_offset);
(scroll.node_id, grab_offset, offset)
});
if let Some((node_id, grab_offset, offset)) = scrollbar_hit {
state.scroll_anim = None;
state.show_scrollbar(node_id);
state.scroll_drag = Some((node_id, grab_offset));
state.scroll_offsets.insert(node_id, offset);
cmds.push(RenderCmd::RenderScroll {
node_id,
scroll_y: offset,
});
return;
}
}
if let Some(open) = state.open_select.clone() {
if let Some(row) = hit_select_option(&open, x, y) {
if let Some(option_idx) = row_option(&open.rows, row) {
close_select(state, arena, Some(option_idx), msgs, cmds);
}
return;
}
let clicked_self = clicked.is_some_and(|r| r.node_id == open.node_id);
close_select(state, arena, None, msgs, cmds);
if clicked_self {
return;
}
}
if clicked.is_some_and(|r| is_disabled_control(Some(arena), r.node_id)) {
return;
}
let click_node = resolve_click_node(arena, clicked.map(|r| r.node_id));
state.pressed_target = click_node;
let direct_field_hit = clicked.is_some_and(|r| click_node == Some(r.node_id));
let clicked_input = click_node.is_some_and(|id| is_text_field(arena, id));
match button {
MouseButton::Left => {
if clicked_input {
if let Some(node_id) = click_node {
if is_toggle_input(arena, node_id) {
} else if is_button_input(arena, node_id) {
} else if is_range_input(arena, node_id) {
focus_input(state, node_id, arena, msgs, cmds);
if let Some(region) = clicked {
state.drag_selecting = true;
if set_range_at_pointer(state, arena, node_id, region.rect, x, y) {
push_input_render(state, node_id, cmds);
msgs.push(ScriptMsg::RangeInput {
node_id,
value: state.input_value.clone(),
});
}
}
} else {
focus_input(state, node_id, arena, msgs, cmds);
refresh_datalist(state, Some(hit_map), Some(arena), cmds);
if direct_field_hit {
state.drag_selecting = true;
if let Some(region) = clicked {
let idx = if is_textarea_node(arena, node_id) {
textarea_index_at(state, region.rect, x, y)
} else {
let inner_x = region.rect.x;
let inner_w = region.rect.width;
char_index_at_x(
&state.input_value,
state.input_cursor,
inner_w,
(x as usize).saturating_sub(inner_x),
)
};
state.input_sel_anchor = Some(idx);
state.input_cursor = idx;
push_input_render(state, node_id, cmds);
}
}
}
}
} else {
if state.focused_input.is_some() {
blur_input(state, msgs, cmds);
}
if clear_text_selection(state, cmds) {
msgs.push(ScriptMsg::SelectionChange);
}
let selection_node = click_node.or_else(|| find_body(arena));
let selected = selection_node
.is_some_and(|node_id| !blocks_page_selection(hit_map, arena, node_id));
if state.copy_flow && selected {
state.text_sel_mode = SelectionMode::Flow;
state.text_sel_armed = true;
state.drag_origin = Some((x, y));
}
}
}
MouseButton::Right => {
if clear_text_selection(state, cmds) {
msgs.push(ScriptMsg::SelectionChange);
}
if state.copy_block {
state.text_sel_mode = SelectionMode::Block;
state.text_sel_armed = true;
state.drag_origin = Some((x, y));
}
}
_ => {}
}
if button == MouseButton::Left
&& let Some(region) = clicked
&& arena
.get(region.node_id)
.is_some_and(|n| n.node_type == NodeType::Select)
{
if state.focused_input.is_some() {
blur_input(state, msgs, cmds);
}
open_select(state, hit_map, arena, region.node_id, cmds);
}
}
pub fn handle_mouse_move(
state: &mut InputState,
hit_map: &HitMap,
arena: &NodeArena,
x: u32,
y: u32,
msgs: &mut Vec<ScriptMsg>,
cmds: &mut Vec<RenderCmd>,
) {
if let Some(row) = state
.open_select
.as_ref()
.and_then(|open| hit_select_option(open, x, y))
&& let Some(idx) = state
.open_select
.as_ref()
.and_then(|open| row_option(&open.rows, row))
&& state
.open_select
.as_ref()
.is_some_and(|o| o.highlight != idx)
&& let Some(open) = &mut state.open_select
{
open.highlight = idx;
cmds.push(RenderCmd::SelectMove {
node_id: open.node_id,
highlight: idx,
});
}
if let Some((node_id, grab_offset)) = state.scroll_drag
&& state.scroll_drag_x.is_some()
&& let Some(scroll) = hit_map.scrollbars.get(&node_id)
{
state.show_scrollbar(node_id);
let offset = scroll.offset_at_x(x, grab_offset);
if state.scroll_x_offsets.get(&node_id) != Some(&offset) {
state.scroll_x_offsets.insert(node_id, offset);
cmds.push(RenderCmd::RenderScrollX {
node_id,
scroll_x: offset,
});
}
return;
}
if let Some((node_id, grab_offset)) = state.scroll_drag
&& let Some(scroll) = hit_map.scrollbars.get(&node_id)
{
state.show_scrollbar(node_id);
let offset = scroll.offset_at(y, grab_offset);
if state.scroll_offsets.get(&node_id) != Some(&offset) {
state.scroll_offsets.insert(node_id, offset);
cmds.push(RenderCmd::RenderScroll {
node_id,
scroll_y: offset,
});
}
return;
}
if state.pressed.is_some() {
if state.drag_selecting {
if !state.field_drag_started {
let Some((ax, ay)) = state.drag_origin else {
return;
};
if x.abs_diff(ax).max(y.abs_diff(ay)) < TEXT_SEL_DRAG_THRESHOLD {
return;
}
state.field_drag_started = true;
}
if let Some(focused) = state.focused_input
&& let Some(region) = hit_map.region_for(focused)
{
if state.focused_is_range {
if set_range_at_pointer(state, arena, focused, region.rect, x, y) {
push_input_render(state, focused, cmds);
msgs.push(ScriptMsg::RangeInput {
node_id: focused,
value: state.input_value.clone(),
});
}
msgs.push(ScriptMsg::MouseMove { x, y });
return;
}
let idx = if state.focused_is_textarea {
textarea_index_at(state, region.rect, x, y)
} else {
let inner_x = region.rect.x;
let inner_w = region.rect.width;
char_index_at_x(
&state.input_value,
state.input_cursor,
inner_w,
(x as usize).saturating_sub(inner_x),
)
};
state.input_cursor = idx;
push_input_render(state, focused, cmds);
}
} else {
if !state.text_sel_armed {
return;
}
let anchor = match state.text_sel_anchor {
Some(a) => a,
None => {
let Some(&(ax, ay)) = state.drag_origin.as_ref() else {
return;
};
if x.abs_diff(ax).max(y.abs_diff(ay)) < TEXT_SEL_DRAG_THRESHOLD {
return;
}
state.text_sel_anchor = Some((ax, ay));
(ax, ay)
}
};
state.text_sel_end = Some((x, y));
cmds.push(RenderCmd::TextSelection {
anchor: Some(anchor),
end: Some((x, y)),
mode: state.text_sel_mode,
});
}
return;
}
msgs.push(ScriptMsg::Move { x, y });
}
pub(super) fn canvas_pos(state: &InputState, column: u16, row: u16) -> (u32, u32) {
(column as u32, row as u32 + state.scroll_y)
}