use rdom_core::{Dom, NodeId, NodeType, Position};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::ext::TuiExt;
use crate::layout::{LayoutRect, Overflow};
use crate::node::TuiNodeExt;
use crate::render::inline::{InlineFragment, has_inline_layout};
use crate::runtime::selection::user_select;
pub trait HitTestExt {
fn hit_test(&self, x: u16, y: u16) -> Option<NodeId>;
fn hit_test_path(&self, x: u16, y: u16) -> Vec<NodeId>;
fn position_at(&self, x: u16, y: u16) -> Option<Position>;
}
impl HitTestExt for Dom<TuiExt> {
fn hit_test(&self, x: u16, y: u16) -> Option<NodeId> {
self.hit_test_path(x, y).last().copied()
}
fn hit_test_path(&self, x: u16, y: u16) -> Vec<NodeId> {
let mut path = Vec::new();
let positioned = collect_positioned_reverse_z(self);
for id in positioned {
if let Some(rect) = self.node(id).layout_rect()
&& rect_contains(rect, x, y)
&& descend(self, id, x, y, &mut path)
{
return path;
}
}
descend(self, self.root(), x, y, &mut path);
path
}
fn position_at(&self, x: u16, y: u16) -> Option<Position> {
let path = self.hit_test_path(x, y);
let target = path
.iter()
.rev()
.find_map(|&id| inline_target_at(self, id, y))?;
if user_select::has_none_ancestor(self, *path.last()?) {
return None;
}
let (inline_layout, content) = target.layout_and_rect(self)?;
match fragment_at_layout(inline_layout, content, x, y) {
Some(fragment) => {
let cell_offset_in_frag = (x as i32 - content.x - fragment.x as i32).max(0) as u16;
let bytes_into_text = cells_to_bytes(&fragment.text, cell_offset_in_frag);
Some(Position::new(
fragment.text_node,
fragment.source_byte_offset + bytes_into_text,
))
}
None => clamp_to_line_layout(inline_layout, content, x, y),
}
}
}
#[derive(Debug, Clone, Copy)]
enum InlineTarget {
Ifc(NodeId),
Anonymous { container: NodeId, index: usize },
}
impl InlineTarget {
fn layout_and_rect(
self,
dom: &Dom<TuiExt>,
) -> Option<(&crate::render::inline::InlineLayout, LayoutRect)> {
match self {
InlineTarget::Ifc(id) => {
let ext = dom.node(id).ext()?;
let layout = ext.inline_layout.as_ref()?;
let content = dom.node(id).content_layout_rect()?;
Some((layout, content))
}
InlineTarget::Anonymous { container, index } => {
let ext = dom.node(container).ext()?;
let anon = ext.anonymous_blocks.get(index)?;
Some((&anon.inline_layout, anon.rect))
}
}
}
}
fn inline_target_at(dom: &Dom<TuiExt>, id: NodeId, y: u16) -> Option<InlineTarget> {
if has_inline_layout(dom, id) {
return Some(InlineTarget::Ifc(id));
}
let ext = dom.node(id).ext()?;
if ext.anonymous_blocks.is_empty() {
return None;
}
let y_i = y as i32;
for (i, anon) in ext.anonymous_blocks.iter().enumerate() {
let top = anon.rect.y;
let bottom = anon.rect.y + anon.rect.height as i32;
if y_i >= top && y_i < bottom {
return Some(InlineTarget::Anonymous {
container: id,
index: i,
});
}
}
None
}
fn clamp_to_line_layout(
layout: &crate::render::inline::InlineLayout,
content: crate::layout::LayoutRect,
x: u16,
y: u16,
) -> Option<Position> {
if layout.lines.is_empty() {
return None;
}
let line_idx = if (y as i32) < content.y {
0
} else {
let raw = (y as i32 - content.y) as usize;
raw.min(layout.lines.len() - 1)
};
let target_line = &layout.lines[line_idx];
if target_line.fragments.is_empty() {
for line in layout.lines.iter().rev() {
if let Some(frag) = line.fragments.last() {
return Some(Position::new(
frag.text_node,
frag.source_byte_offset + frag.text.len(),
));
}
}
return None;
}
let first = target_line.fragments.first().unwrap();
let last = target_line.fragments.last().unwrap();
let line_left = content.x + first.x as i32;
let line_right = content.x + last.x as i32 + last.width as i32;
if (x as i32) < line_left {
Some(Position::new(first.text_node, first.source_byte_offset))
} else if (x as i32) >= line_right {
Some(Position::new(
last.text_node,
last.source_byte_offset + last.text.len(),
))
} else {
Some(Position::new(
last.text_node,
last.source_byte_offset + last.text.len(),
))
}
}
fn descend(dom: &Dom<TuiExt>, id: NodeId, x: u16, y: u16, path: &mut Vec<NodeId>) -> bool {
let ty = dom.node(id).node_type();
if ty == NodeType::Fragment {
return descend_children_reverse(dom, id, x, y, path);
}
if ty != NodeType::Element {
return false;
}
let display = dom
.node(id)
.computed()
.map(|c| c.display)
.unwrap_or(crate::layout::Display::Block);
if matches!(display, crate::layout::Display::None) {
return false;
}
let outer = match dom.node(id).layout_rect() {
Some(r) if rect_contains(r, x, y) => r,
_ => return false,
};
path.push(id);
let computed = dom.node(id).computed();
let clips_children = computed.is_some_and(|c| {
!matches!(c.overflow_x, Overflow::Visible) || !matches!(c.overflow_y, Overflow::Visible)
});
let inner = dom.node(id).content_layout_rect().unwrap_or(outer);
let scrollport = computed
.map(|c| rdom_style::layout::compute_padding_box(outer, c.border))
.unwrap_or(outer);
if clips_children && !rect_contains(scrollport, x, y) {
return true; }
if has_inline_layout(dom, id) {
if let Some(owner) = hit_fragment(dom, id, inner, x, y)
&& owner != id
{
append_inline_ancestors(dom, id, owner, path);
}
return true;
}
descend_children_reverse(dom, id, x, y, path);
true
}
fn descend_children_reverse(
dom: &Dom<TuiExt>,
id: NodeId,
x: u16,
y: u16,
path: &mut Vec<NodeId>,
) -> bool {
let child_ids: Vec<NodeId> = dom.node(id).child_nodes().map(|n| n.id()).collect();
for &child in child_ids.iter().rev() {
if is_positioned(dom, child) {
continue;
}
if descend(dom, child, x, y, path) {
return true;
}
}
false
}
fn is_positioned(dom: &Dom<TuiExt>, id: NodeId) -> bool {
dom.node(id)
.ext()
.and_then(|e| e.computed.as_ref())
.map(|c| {
matches!(
c.position,
crate::layout::Position::Absolute | crate::layout::Position::Fixed
)
})
.unwrap_or(false)
}
fn collect_positioned_reverse_z(dom: &Dom<TuiExt>) -> Vec<NodeId> {
let mut list: Vec<(i16, usize, NodeId)> = Vec::new();
let mut order: usize = 0;
walk_for_positioned(dom, dom.root(), &mut list, &mut order);
list.sort_by_key(|(z, ord, _)| (*z, *ord));
list.reverse();
list.into_iter().map(|(_, _, id)| id).collect()
}
fn walk_for_positioned(
dom: &Dom<TuiExt>,
id: NodeId,
out: &mut Vec<(i16, usize, NodeId)>,
order: &mut usize,
) {
if let Some(computed) = dom.node(id).ext().and_then(|e| e.computed.as_ref())
&& matches!(
computed.position,
crate::layout::Position::Absolute | crate::layout::Position::Fixed
)
{
let z = match computed.z_index {
crate::layout::ZIndex::Auto => 0,
crate::layout::ZIndex::Value(n) => n,
};
out.push((z, *order, id));
*order += 1;
}
for child in dom.node(id).child_nodes() {
walk_for_positioned(dom, child.id(), out, order);
}
}
fn hit_fragment(
dom: &Dom<TuiExt>,
ifc_block: NodeId,
content: LayoutRect,
x: u16,
y: u16,
) -> Option<NodeId> {
let ext = dom.node(ifc_block).ext()?;
let layout = ext.inline_layout.as_ref()?;
let line_index = y as i32 - content.y;
if line_index < 0 || line_index as usize >= layout.lines.len() {
return None;
}
let line = &layout.lines[line_index as usize];
let x_local_i = x as i32 - content.x;
if x_local_i < 0 {
return None;
}
let x_local = x_local_i as u16;
for fragment in &line.fragments {
if x_local >= fragment.x && x_local < fragment.x + fragment.width {
return Some(fragment.node);
}
}
None
}
fn append_inline_ancestors(
dom: &Dom<TuiExt>,
ifc_block: NodeId,
owner: NodeId,
path: &mut Vec<NodeId>,
) {
let mut chain = Vec::new();
let mut cur = owner;
while cur != ifc_block {
chain.push(cur);
match dom.node(cur).parent_node() {
Some(parent) => cur = parent.id(),
None => break, }
}
chain.reverse();
path.extend(chain);
}
#[inline]
fn rect_contains(r: LayoutRect, x: u16, y: u16) -> bool {
let x = x as i32;
let y = y as i32;
x >= r.x && x < r.x + r.width as i32 && y >= r.y && y < r.y + r.height as i32
}
fn fragment_at_layout(
layout: &crate::render::inline::InlineLayout,
content: LayoutRect,
x: u16,
y: u16,
) -> Option<&InlineFragment> {
let line_index = y as i32 - content.y;
if line_index < 0 || line_index as usize >= layout.lines.len() {
return None;
}
let line = &layout.lines[line_index as usize];
let x_local_i = x as i32 - content.x;
if x_local_i < 0 {
return None;
}
let x_local = x_local_i as u16;
line.fragments
.iter()
.find(|&fragment| x_local >= fragment.x && x_local < fragment.x + fragment.width)
.map(|v| v as _)
}
fn cells_to_bytes(text: &str, target_cells: u16) -> usize {
let mut consumed_cells: u16 = 0;
for (idx, g) in text.grapheme_indices(true) {
let w = UnicodeWidthStr::width(g) as u16;
if target_cells < consumed_cells.saturating_add(w) {
return idx;
}
consumed_cells = consumed_cells.saturating_add(w);
}
text.len()
}
#[cfg(test)]
mod tests;