htui 0.1.0

A terminal HTML/CSS renderer with an embedded JavaScript engine — build interactive TUIs from HTML, CSS and JS
Documentation
use crate::tdom::node::{NodeArena, NodeId, NodeType};

use super::selector::{
    Combinator, CompoundSelector, CssSelector, DynamicState, NthExpr, PseudoClass, Selector,
};

impl PseudoClass {
    /// The matching semantics of a dynamic pseudo-class: read the slot of
    /// `state` this pseudo-class owns and judge `node_id` by its DOM rule.
    /// `:hover` follows the ancestor chain (the pointer being over a
    /// descendant counts); `:focus` matches the focused element itself.
    pub fn matches(&self, arena: &NodeArena, node_id: NodeId, state: &DynamicState) -> bool {
        match self {
            PseudoClass::Hover => state
                .hovered
                .is_some_and(|h| is_hovered_or_ancestor(arena, node_id, h)),
            PseudoClass::Focus => state.focused == Some(node_id),
        }
    }

    /// The nodes whose painted output can change when this pseudo-class's
    /// state target moves to `target`: the target plus (for `:hover`) every
    /// ancestor that `:hover` rules can match. `:focus` is exact — only the
    /// focused element's own boxes can be restyled.
    pub fn affected_candidates(&self, arena: &NodeArena, target: Option<NodeId>) -> Vec<NodeId> {
        match self {
            PseudoClass::Hover => ancestor_chain(arena, target),
            PseudoClass::Focus => target.into_iter().collect(),
        }
    }

    /// A [`DynamicState`] with this pseudo-class's slot set to `target` and
    /// every other slot cleared — the match context for deciding whether a
    /// rule is affected by a state change of this pseudo-class alone.
    pub fn state_with_target(&self, target: Option<NodeId>) -> DynamicState {
        match self {
            PseudoClass::Hover => DynamicState {
                hovered: target,
                focused: None,
            },
            PseudoClass::Focus => DynamicState {
                hovered: None,
                focused: target,
            },
        }
    }
}

/// `target` and its ancestors, target-first, ending at the root (or at the
/// root-most reachable node for a detached node).
fn ancestor_chain(arena: &NodeArena, target: Option<NodeId>) -> Vec<NodeId> {
    let mut out = Vec::new();
    let mut cur = target;
    while let Some(id) = cur {
        out.push(id);
        cur = arena.get(id).and_then(|n| n.parent);
    }
    out
}

fn matches_css(arena: &NodeArena, node_id: NodeId, sel: &Selector, state: &DynamicState) -> bool {
    let node = &arena[node_id];
    match sel {
        // `*` matches every element node, never non-element nodes (text,
        // comment, document, doctype, document-fragment).
        Selector::Universal => !matches!(
            node.node_type,
            NodeType::Document
                | NodeType::DocumentType
                | NodeType::DocumentFragment
                | NodeType::Text
                | NodeType::Comment
        ),
        Selector::Tag(nt) => node.node_type == *nt,
        Selector::Class(name) => node.has_class(name),
        Selector::Id(id) => node
            .get_id()
            .map(|s| s.as_str() == id.as_str())
            .unwrap_or(false),
        Selector::NthChild(expr) => nth_child_matches(arena, node_id, *expr),
        Selector::PseudoClass(p) => p.matches(arena, node_id, state),
    }
}

fn matches_compound(
    arena: &NodeArena,
    node_id: NodeId,
    compound: &CompoundSelector,
    state: &DynamicState,
) -> bool {
    compound
        .iter()
        .all(|s| matches_css(arena, node_id, s, state))
}

/// Compute the 1-based sibling index of `node_id` among its parent's
/// children. Text nodes count toward the index (matching the CSS spec) so
/// that `<div>text<p>...</p></div>` reports `<p>` as index 2, not 1.
fn nth_child_matches(arena: &NodeArena, node_id: NodeId, expr: NthExpr) -> bool {
    let Some(parent_id) = arena[node_id].parent else {
        // Detached node — `:nth-child` is meaningless without a sibling
        // list, so decline to match.
        return false;
    };
    let parent = &arena[parent_id];
    let mut index = 1usize;
    for &cid in &parent.children {
        if cid == node_id {
            return expr.matches(index);
        }
        index = index.saturating_add(1);
    }
    // `node_id` not listed as a child — should not happen for a well-formed
    // DOM, but treat as no-match rather than panicking.
    false
}

/// Check whether a node matches a CSS selector chain, with no `:hover`
/// context (i.e. `:hover` rules never match).
#[doc(hidden)]
pub fn selector_chain_matches(arena: &NodeArena, node_id: NodeId, selector: &CssSelector) -> bool {
    selector_chain_matches_with_hover(arena, node_id, selector, None)
}

/// Like [`selector_chain_matches`], but evaluates the dynamic pseudo-classes
/// against a session [`DynamicState`] snapshot.
///
/// A stale node id from a previous layout session simply fails to match (it
/// is looked up via `arena.get`, never indexed directly).
pub fn selector_chain_matches_with_state(
    arena: &NodeArena,
    node_id: NodeId,
    selector: &CssSelector,
    state: &DynamicState,
) -> bool {
    let n = selector.compounds.len();
    if n == 0 {
        return false;
    }
    if !matches_compound(arena, node_id, &selector.compounds[n - 1], state) {
        return false;
    }
    if n == 1 {
        return true;
    }
    match_combinators(
        arena,
        node_id,
        &selector.compounds,
        &selector.combinators,
        n - 1,
        state,
    )
}

/// Like [`selector_chain_matches`], but evaluates `:hover` against the node
/// currently under the pointer (`:focus` never matches).
pub fn selector_chain_matches_with_hover(
    arena: &NodeArena,
    node_id: NodeId,
    selector: &CssSelector,
    hovered: Option<NodeId>,
) -> bool {
    selector_chain_matches_with_state(
        arena,
        node_id,
        selector,
        &DynamicState {
            hovered,
            focused: None,
        },
    )
}

/// True when `node_id` is the hovered element or one of its ancestors — the
/// CSS `:hover` semantics (the pointer being over a descendant counts).
fn is_hovered_or_ancestor(arena: &NodeArena, node_id: NodeId, hovered: NodeId) -> bool {
    let mut current = Some(hovered);
    while let Some(id) = current {
        if id == node_id {
            return true;
        }
        current = arena.get(id).and_then(|n| n.parent);
    }
    false
}

fn match_combinators(
    arena: &NodeArena,
    node_id: NodeId,
    compounds: &[CompoundSelector],
    combinators: &[Combinator],
    idx: usize,
    state: &DynamicState,
) -> bool {
    if idx == 0 {
        return true;
    }
    let combinator = combinators[idx - 1];
    let target = &compounds[idx - 1];

    match combinator {
        Combinator::Descendant => {
            let mut cur = arena[node_id].parent;
            while let Some(pid) = cur {
                let ancestor = &arena[pid];
                if matches_compound(arena, pid, target, state)
                    && match_combinators(arena, pid, compounds, combinators, idx - 1, state)
                {
                    return true;
                }
                cur = ancestor.parent;
            }
            false
        }
        Combinator::Child => match arena[node_id].parent {
            Some(pid) if matches_compound(arena, pid, target, state) => {
                match_combinators(arena, pid, compounds, combinators, idx - 1, state)
            }
            _ => false,
        },
        Combinator::NextSibling => match prev_sibling_id(arena, node_id) {
            Some(sid) if matches_compound(arena, sid, target, state) => {
                match_combinators(arena, sid, compounds, combinators, idx - 1, state)
            }
            _ => false,
        },
        Combinator::SubsequentSibling => {
            let mut cur = prev_sibling_id(arena, node_id);
            while let Some(sid) = cur {
                if matches_compound(arena, sid, target, state)
                    && match_combinators(arena, sid, compounds, combinators, idx - 1, state)
                {
                    return true;
                }
                cur = prev_sibling_id(arena, sid);
            }
            false
        }
    }
}

fn prev_sibling_id(arena: &NodeArena, node_id: NodeId) -> Option<NodeId> {
    let node = &arena[node_id];
    let parent_id = node.parent?;
    let parent = &arena[parent_id];
    let mut prev = None;
    for &cid in &parent.children {
        if cid == node_id {
            return prev;
        }
        prev = Some(cid);
    }
    None
}