use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::events::{
ElementRef, EventBindings, EventType, HitMap, HitRegion, HoverState, KeyEventInfo, interaction,
};
use crate::layout::Rect;
use crate::script::ScriptEngine;
use crate::tdom::node::{NodeArena, NodeId, NodeType};
use crate::tui::graph_api::{DirtyInfo, RenderCmd};
#[derive(Debug)]
pub enum ScriptMsg {
Move { x: u32, y: u32 },
Click { x: u32, y: u32 },
FocusInput { node_id: NodeId },
BlurInput { node_id: NodeId, value: String },
InputKeystroke { node_id: NodeId },
SelectPick { node_id: NodeId, value: String },
ToggleCheck { node_id: NodeId },
FormSubmit { form_id: NodeId },
KeyDown {
key_id: u32,
target: Option<NodeId>,
key: KeyEventInfo,
},
KeyUp {
target: Option<NodeId>,
key: KeyEventInfo,
},
Quit,
}
#[derive(Debug)]
pub struct ScriptDirty {
pub arena: Arc<NodeArena>,
pub root_id: NodeId,
pub dirty: DirtyInfo,
}
#[derive(Debug)]
pub struct ScriptEvent {
pub cmds: Vec<RenderCmd>,
pub dirty: Option<ScriptDirty>,
pub hovered: Option<NodeId>,
pub key_gate: Option<(u32, bool)>,
}
#[derive(Debug)]
pub enum ScriptResponse {
Ready {
arena: Arc<NodeArena>,
root_id: NodeId,
},
Failed(String),
Event(ScriptEvent),
}
fn summary_details(arena: &NodeArena, node_id: NodeId) -> Option<NodeId> {
let mut current = Some(node_id);
while let Some(id) = current {
let node = arena.get(id)?;
if node.node_type == NodeType::Summary {
return node
.parent
.filter(|p| arena[*p].node_type == NodeType::Details);
}
current = node.parent;
}
None
}
fn dialog_form_action(arena: &NodeArena, node_id: NodeId) -> Option<NodeId> {
let node = arena.get(node_id)?;
if node.attributes.contains_key("disabled") {
return None;
}
let is_submit = match node.node_type {
NodeType::Button => !matches!(
node.attributes
.get("type")
.map(|t| t.to_ascii_lowercase())
.as_deref(),
Some("button")
),
NodeType::Input => matches!(
node.attributes
.get("type")
.map(|t| t.to_ascii_lowercase())
.as_deref(),
Some("submit") | Some("image")
),
_ => false,
};
if !is_submit {
return None;
}
let mut current = node.parent;
while let Some(pid) = current {
if arena[pid].node_type == NodeType::Form {
if !arena[pid]
.attributes
.get("method")
.is_some_and(|m| m.eq_ignore_ascii_case("dialog"))
{
return None;
}
let mut up = arena[pid].parent;
while let Some(uid) = up {
if arena[uid].node_type == NodeType::Dialog {
return Some(uid);
}
up = arena[uid].parent;
}
return None;
}
current = arena[pid].parent;
}
None
}
fn button_form_action(arena: &NodeArena, node_id: NodeId) -> Option<(NodeId, EventType)> {
let node = arena.get(node_id)?;
if node.attributes.contains_key("disabled") {
return None;
}
let input_type = node.attributes.get("type").map(|t| t.to_ascii_lowercase());
let action = match node.node_type {
NodeType::Button => match input_type.as_deref() {
Some("reset") => EventType::Reset,
Some("button") => return None,
_ => EventType::Submit,
},
NodeType::Input => match input_type.as_deref() {
Some("submit") | Some("image") => EventType::Submit,
Some("reset") => EventType::Reset,
_ => return None,
},
_ => return None,
};
let mut current = node.parent;
while let Some(pid) = current {
if arena[pid].node_type == NodeType::Form {
return Some((pid, action));
}
current = arena[pid].parent;
}
None
}
pub fn apply_message(
engine: &mut ScriptEngine,
hover: &mut HoverState,
hit_map: &HitMap,
msg: ScriptMsg,
) -> ScriptEvent {
let (cmds, key_gate) = match &msg {
ScriptMsg::Move { x, y } => (interaction::on_move(hover, hit_map, engine, *x, *y), None),
ScriptMsg::Click { x, y } => {
let mut cmds = interaction::on_click(hover, hit_map, engine, *x, *y);
if let Some(details_id) = hit_map
.hit_test(*x, *y)
.and_then(|r| summary_details(&engine.arena(), r.node_id))
{
engine.toggle_details(details_id);
let region = region_for(&engine.arena(), details_id);
cmds.extend(engine.fire(®ion, EventType::Toggle, 0, 0));
}
if let Some((form_id, et)) = hit_map
.hit_test(*x, *y)
.and_then(|r| button_form_action(&engine.arena(), r.node_id))
{
let region = region_for(&engine.arena(), form_id);
cmds.extend(engine.fire(®ion, et, 0, 0));
}
if let Some(dialog_id) = hit_map
.hit_test(*x, *y)
.and_then(|r| dialog_form_action(&engine.arena(), r.node_id))
&& engine.close_dialog(dialog_id)
{
let region = region_for(&engine.arena(), dialog_id);
cmds.extend(engine.fire(®ion, EventType::Close, 0, 0));
}
(cmds, None)
}
ScriptMsg::FocusInput { node_id } => {
let region = region_for(&engine.arena(), *node_id);
(engine.fire(®ion, EventType::Focus, 0, 0), None)
}
ScriptMsg::InputKeystroke { node_id } => {
let region = region_for(&engine.arena(), *node_id);
(engine.fire(®ion, EventType::Input, 0, 0), None)
}
ScriptMsg::BlurInput { node_id, value } => {
let arena = engine.arena();
let is_textarea = arena
.get(*node_id)
.is_some_and(|n| n.node_type == NodeType::Textarea);
let old = if is_textarea {
arena
.get(*node_id)
.map(|n| n.text_content(&arena))
.unwrap_or_default()
} else {
arena
.get(*node_id)
.and_then(|n| n.attributes.get("value"))
.cloned()
.unwrap_or_default()
};
if is_textarea {
engine.set_textarea_value(*node_id, value);
} else {
engine.set_value(*node_id, value);
}
let region = region_for(&engine.arena(), *node_id);
let mut cmds = engine.fire(®ion, EventType::Blur, 0, 0);
if old != *value {
cmds.extend(engine.fire(®ion, EventType::Change, 0, 0));
}
(cmds, None)
}
ScriptMsg::SelectPick { node_id, value } => {
let arena = engine.arena();
let old = arena
.get(*node_id)
.and_then(|n| n.attributes.get("value"))
.cloned()
.unwrap_or_default();
if old != *value {
engine.set_value_dirty(*node_id, value);
let region = region_for(&engine.arena(), *node_id);
(engine.fire(®ion, EventType::Change, 0, 0), None)
} else {
(Vec::new(), None)
}
}
ScriptMsg::ToggleCheck { node_id } => {
let node_id = *node_id;
let is_radio = engine.arena().get(node_id).is_some_and(|n| {
n.get_type()
.is_some_and(|t| t.eq_ignore_ascii_case("radio"))
});
let should_check = if is_radio {
true
} else {
!engine
.arena()
.get(node_id)
.is_some_and(|n| n.attributes.contains_key("checked"))
};
let changed = engine.set_checked(node_id, should_check);
if changed {
let region = region_for(&engine.arena(), node_id);
let mut cmds = engine.fire(®ion, EventType::Input, 0, 0);
cmds.extend(engine.fire(®ion, EventType::Change, 0, 0));
(cmds, None)
} else {
(Vec::new(), None)
}
}
ScriptMsg::FormSubmit { form_id } => {
let region = region_for(&engine.arena(), *form_id);
(engine.fire(®ion, EventType::Submit, 0, 0), None)
}
ScriptMsg::KeyDown {
key_id,
target,
key,
} => {
let region = key_region(engine, *target);
let (cmds, prevented) = engine.fire_key_gated(®ion, EventType::KeyDown, key);
(cmds, Some((*key_id, prevented)))
}
ScriptMsg::KeyUp { target, key } => {
let region = key_region(engine, *target);
(engine.fire_key(®ion, EventType::KeyUp, key), None)
}
ScriptMsg::Quit => (Vec::new(), None),
};
let dirty = {
let dirty = engine.take_dirty();
if dirty.is_empty() {
None
} else {
Some(ScriptDirty {
arena: Arc::new(engine.arena()),
root_id: engine.root_id(),
dirty,
})
}
};
let hovered = hover.current().map(|r| r.node_id);
ScriptEvent {
cmds,
dirty,
hovered,
key_gate,
}
}
pub fn run_script_thread(
html: Arc<str>,
bindings: EventBindings,
rx: Receiver<ScriptMsg>,
tx: Sender<ScriptResponse>,
hit_map: Arc<Mutex<Arc<HitMap>>>,
) {
let mut engine = match ScriptEngine::new(html.as_ref(), bindings) {
Ok(engine) => engine,
Err(err) => {
log::error!("Failed to initialize script engine: {err}");
if tx.send(ScriptResponse::Failed(err.to_string())).is_err() {
log::debug!("Script response channel closed during init: {err}");
}
return;
}
};
let mut hover = HoverState::new();
if tx
.send(ScriptResponse::Ready {
arena: Arc::new(engine.arena()),
root_id: engine.root_id(),
})
.is_err()
{
log::debug!("Script response channel closed before first frame");
return;
}
loop {
let wait = match engine.next_timer_timeout() {
Some(deadline) => Some(deadline),
None if engine.has_pending_fetch() => Some(Duration::ZERO),
None => None,
};
let msg = match wait {
Some(wait) => match rx.recv_timeout(wait) {
Ok(msg) => Some(msg),
Err(RecvTimeoutError::Timeout) => None,
Err(RecvTimeoutError::Disconnected) => break,
},
None => match rx.recv() {
Ok(msg) => Some(msg),
Err(_) => break,
},
};
let has_msg = msg.is_some();
let mut event = match msg {
Some(ScriptMsg::Quit) => break,
Some(msg) => {
let map = match hit_map.lock() {
Ok(guard) => Arc::clone(&guard),
Err(e) => {
log::error!("Hit map lock poisoned: {}", e);
Arc::new(HitMap::new())
}
};
apply_message(&mut engine, &mut hover, &map, msg)
}
None => ScriptEvent {
cmds: Vec::new(),
dirty: None,
hovered: hover.current().map(|r| r.node_id),
key_gate: None,
},
};
event.cmds.extend(engine.run_due_timers());
engine.poll_fetches();
event.cmds.extend(engine.take_pending_cmds());
let extra = engine.take_dirty();
if !extra.is_empty() {
let snapshot = ScriptDirty {
arena: Arc::new(engine.arena()),
root_id: engine.root_id(),
dirty: extra,
};
event.dirty = match event.dirty {
Some(mut d) => {
d.dirty.nodes.extend(snapshot.dirty.nodes);
d.dirty.injected_css.extend(snapshot.dirty.injected_css);
d.arena = snapshot.arena;
d.root_id = snapshot.root_id;
Some(d)
}
None => Some(snapshot),
};
}
if !has_msg && event.cmds.is_empty() && event.dirty.is_none() && event.key_gate.is_none() {
continue;
}
if tx.send(ScriptResponse::Event(event)).is_err() {
log::debug!("Script response channel closed");
break;
}
}
log::debug!("Script thread exiting");
}
pub(crate) fn key_region(engine: &ScriptEngine, target: Option<NodeId>) -> HitRegion {
let arena = engine.arena();
let id = match target {
Some(id) if arena.contains_key(id) => id,
_ => arena
.iter()
.find(|(_, n)| n.node_type == NodeType::Body)
.map(|(id, _)| id)
.unwrap_or_else(|| engine.root_id()),
};
region_for(&arena, id)
}
pub(crate) fn region_for(arena: &NodeArena, node_id: NodeId) -> HitRegion {
let mut path = Vec::new();
let mut current = Some(node_id);
while let Some(id) = current {
let Some(node) = arena.get(id) else {
break;
};
path.push(ElementRef {
id: node.get_id().cloned(),
node_id: id,
});
current = node.parent;
}
HitRegion {
node_id,
rect: Rect::new(0, 0, 0, 0),
path,
}
}