use std::sync::Arc;
use std::sync::Mutex;
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
use std::time::Duration;
use compact_str::CompactString;
use crate::events::{
ElementRef, EventBindings, EventType, HitMap, HitRegion, HoverState, KeyEventInfo,
SharedHitMap, interaction,
};
use crate::html::html_parse::ParsedDocument;
use crate::layout::Rect;
use crate::script::ScriptEngine;
use crate::script::{SharedUiState, set_hit_map, set_selection_text, set_ui_state};
use crate::source::SharedSourceText;
use crate::tdom::node::{NodeArena, NodeId, NodeType, SharedNodeArena};
use crate::tui::graph_api::{DirtyInfo, RenderCmd};
#[derive(Debug)]
pub enum ScriptMsg {
Move { x: u32, y: u32 },
Click { x: u32, y: u32 },
MouseDown { x: u32, y: u32 },
MouseUp { x: u32, y: u32 },
MouseMove { x: u32, y: u32 },
DblClick { x: u32, y: u32 },
ContextMenu { x: u32, y: u32 },
Wheel {
gate_id: u32,
x: u32,
y: u32,
up: bool,
},
FocusInput { node_id: NodeId },
BlurInput {
node_id: NodeId,
value: CompactString,
},
InputKeystroke { node_id: NodeId },
RangeInput {
node_id: NodeId,
value: CompactString,
},
SelectPick {
node_id: NodeId,
value: CompactString,
},
ToggleCheck { node_id: NodeId },
FormSubmit { form_id: NodeId },
KeyDown {
key_id: u32,
target: Option<NodeId>,
key: KeyEventInfo,
},
KeyUp {
target: Option<NodeId>,
key: KeyEventInfo,
},
Resize,
Scroll,
SelectionChange,
ReloadHtml { html: SharedSourceText },
ReloadJs { js: SharedSourceText },
Quit,
}
#[derive(Debug)]
pub struct ScriptDirty {
pub arena: SharedNodeArena,
pub root_id: NodeId,
pub dirty: DirtyInfo,
pub document_css: Option<SharedSourceText>,
}
#[derive(Debug)]
pub struct ScriptEvent {
pub cmds: Vec<RenderCmd>,
pub dirty: Option<ScriptDirty>,
pub hovered: Option<NodeId>,
pub key_gate: Option<(u32, bool)>,
pub wheel_gate: Option<(u32, bool)>,
}
#[derive(Debug)]
pub enum ScriptResponse {
Ready {
arena: SharedNodeArena,
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, wheel_gate) = match &msg {
ScriptMsg::Move { x, y } => (
interaction::on_move(hover, hit_map, engine, *x, *y),
None,
None,
),
ScriptMsg::MouseDown { x, y } => {
let cmds = interaction::on_mouse_down(hit_map, engine, *x, *y);
(cmds, None, None)
}
ScriptMsg::MouseUp { x, y } => {
let cmds = interaction::on_mouse_up(hit_map, engine, *x, *y);
(cmds, None, None)
}
ScriptMsg::MouseMove { x, y } => {
let cmds = interaction::on_mouse_move_dispatch(hit_map, engine, *x, *y);
(cmds, None, None)
}
ScriptMsg::DblClick { x, y } => match hit_map.hit_test(*x, *y) {
Some(region) => (engine.fire(region, EventType::DblClick, *x, *y), None, None),
None => (Vec::new(), None, None),
},
ScriptMsg::ContextMenu { x, y } => match hit_map.hit_test(*x, *y) {
Some(region) => (
engine.fire(region, EventType::ContextMenu, *x, *y),
None,
None,
),
None => (Vec::new(), None, None),
},
ScriptMsg::Wheel { gate_id, x, y, up } => {
let (cmds, prevented) = engine.fire_wheel_gate(hit_map, *x, *y, *up);
(cmds, None, Some((*gate_id, prevented)))
}
ScriptMsg::Click { x, y } => {
let mut cmds = interaction::on_click(hover, hit_map, engine, *x, *y);
let clicked = hit_map.hit_test(*x, *y).map(|r| r.node_id);
if let Some(details_id) =
clicked.and_then(|node_id| summary_details(&engine.arena(), 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)) =
clicked.and_then(|node_id| button_form_action(&engine.arena(), node_id))
{
let region = region_for(&engine.arena(), form_id);
cmds.extend(engine.fire(®ion, et, 0, 0));
}
if let Some(dialog_id) =
clicked.and_then(|node_id| dialog_form_action(&engine.arena(), 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, None)
}
ScriptMsg::FocusInput { node_id } => {
let region = region_for(&engine.arena(), *node_id);
(engine.fire(®ion, EventType::Focus, 0, 0), None, None)
}
ScriptMsg::Resize => {
let mut cmds = engine.fire_resize();
engine.evaluate_media_changes();
let mut st_cmds = engine.take_pending_cmds();
cmds.append(&mut st_cmds);
(cmds, None, None)
}
ScriptMsg::Scroll => (engine.fire_scroll(), None, None),
ScriptMsg::SelectionChange => (engine.fire_selection_change(), None, None),
ScriptMsg::InputKeystroke { node_id } => {
let region = region_for(&engine.arena(), *node_id);
(engine.fire(®ion, EventType::Input, 0, 0), None, None)
}
ScriptMsg::RangeInput { node_id, value } => {
let node_id = *node_id;
let old = engine
.arena()
.get(node_id)
.and_then(|n| n.attributes.get("value"))
.cloned()
.unwrap_or_default();
engine.set_value_dirty(node_id, value);
let region = region_for(&engine.arena(), node_id);
let mut cmds = engine.fire(®ion, EventType::Input, 0, 0);
if old != *value {
cmds.extend(engine.fire(®ion, EventType::Change, 0, 0));
}
(cmds, None, 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, 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, None)
} else {
(Vec::new(), None, 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, None)
} else {
(Vec::new(), None, None)
}
}
ScriptMsg::FormSubmit { form_id } => {
let region = region_for(&engine.arena(), *form_id);
(engine.fire(®ion, EventType::Submit, 0, 0), None, 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)), None)
}
ScriptMsg::KeyUp { target, key } => {
let region = key_region(engine, *target);
(engine.fire_key(®ion, EventType::KeyUp, key), None, None)
}
ScriptMsg::ReloadHtml { html } => {
*hover = HoverState::new();
if let Err(e) = engine.reload(html) {
log::error!("Hot reload failed; keeping the previous document: {e}");
}
(Vec::new(), None, None)
}
ScriptMsg::ReloadJs { js } => {
if let Err(e) = engine.reload_js(js) {
log::error!("JS hot reload failed: {e}");
}
(Vec::new(), None, None)
}
ScriptMsg::Quit => (Vec::new(), None, None),
};
let document_reload = engine.take_document_reload();
if document_reload {
*hover = HoverState::new();
}
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,
document_css: document_reload.then(|| engine.document_css()),
})
}
};
let hovered = hover.current().map(|r| r.node_id);
ScriptEvent {
cmds,
dirty,
hovered,
key_gate,
wheel_gate,
}
}
pub fn run_script_thread(
document: ParsedDocument,
bindings: EventBindings,
session_js: SharedSourceText,
rx: Receiver<ScriptMsg>,
tx: Sender<ScriptResponse>,
hit_map: SharedHitMap,
ui: Arc<SharedUiState>,
selection_text: Arc<Mutex<String>>,
) {
set_ui_state(ui);
set_hit_map(Arc::clone(&hit_map));
set_selection_text(selection_text);
let mut engine = match ScriptEngine::new_from_document(document, &session_js, 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;
}
let boot = engine.fire_document_loaded();
if (!boot.cmds.is_empty() || boot.dirty.is_some())
&& tx.send(ScriptResponse::Event(boot)).is_err()
{
log::debug!("Script response channel closed after load events");
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,
wheel_gate: None,
},
};
event.cmds.extend(engine.run_due_timers());
engine.poll_fetches();
event.cmds.extend(engine.take_pending_cmds());
let extra_reload = engine.take_document_reload();
let extra = engine.take_dirty();
if !extra.is_empty() {
let snapshot = ScriptDirty {
arena: Arc::new(engine.arena()),
root_id: engine.root_id(),
dirty: extra,
document_css: extra_reload.then(|| engine.document_css()),
};
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.document_css = d.document_css.or(snapshot.document_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()
&& event.wheel_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),
client: Rect::new(0, 0, 0, 0),
path: path.into(),
}
}