use std::cell::RefCell;
use std::rc::Rc;
use rdom_core::{Dom, InteractionKind, Mutation, MutationObserver, NodeId, ObserverId};
use crate::ext::TuiExt;
use crate::style::sibling_triggers::{Cause, SiblingTriggers};
#[derive(Debug, Clone, Default)]
pub struct DirtyTracker {
inner: Rc<RefCell<DirtyState>>,
observer_id: Option<ObserverId>,
}
#[derive(Debug)]
struct DirtyState {
roots: Vec<NodeId>,
roots_set: std::collections::HashSet<NodeId>,
sibling_marked: std::collections::HashSet<NodeId>,
paint_dirty: bool,
selection_dirty: bool,
records: u64,
siblings: SiblingTriggers,
}
impl Default for DirtyState {
fn default() -> Self {
Self {
roots: Vec::new(),
roots_set: std::collections::HashSet::new(),
sibling_marked: std::collections::HashSet::new(),
paint_dirty: false,
selection_dirty: false,
records: 0,
siblings: SiblingTriggers::all(),
}
}
}
impl DirtyTracker {
pub fn install(dom: &mut Dom<TuiExt>) -> Self {
let inner = Rc::new(RefCell::new(DirtyState::default()));
let shim = Shim {
inner: inner.clone(),
};
let observer_id = dom.add_mutation_observer(Box::new(shim));
Self {
inner,
observer_id: Some(observer_id),
}
}
pub fn uninstall(self, dom: &mut Dom<TuiExt>) -> Vec<NodeId> {
if let Some(id) = self.observer_id {
dom.remove_mutation_observer(id);
}
let mut state = self.inner.borrow_mut();
state.roots_set.clear();
state.sibling_marked.clear();
std::mem::take(&mut state.roots)
}
pub fn take_roots(&self) -> Vec<NodeId> {
let mut state = self.inner.borrow_mut();
state.roots_set.clear();
state.sibling_marked.clear();
std::mem::take(&mut state.roots)
}
pub fn roots_snapshot(&self) -> Vec<NodeId> {
self.inner.borrow().roots.clone()
}
pub fn take_paint_dirty(&self) -> bool {
std::mem::take(&mut self.inner.borrow_mut().paint_dirty)
}
pub fn paint_dirty_snapshot(&self) -> bool {
self.inner.borrow().paint_dirty
}
pub fn take_selection_dirty(&self) -> bool {
std::mem::take(&mut self.inner.borrow_mut().selection_dirty)
}
#[cfg(test)]
pub(crate) fn has_pending(&self) -> bool {
let state = self.inner.borrow();
!state.roots.is_empty() || state.paint_dirty || state.selection_dirty
}
pub(crate) fn records_seen(&self) -> u64 {
self.inner.borrow().records
}
pub fn observer_id(&self) -> Option<ObserverId> {
self.observer_id
}
pub fn set_sibling_combinators(&self, used: bool) {
self.inner.borrow_mut().siblings = if used {
SiblingTriggers::all()
} else {
SiblingTriggers::none()
};
}
pub(crate) fn set_sibling_triggers(&self, triggers: SiblingTriggers) {
self.inner.borrow_mut().siblings = triggers;
}
pub fn mark_dirty(&self, dom: &mut Dom<TuiExt>, id: NodeId) {
let mut state = self.inner.borrow_mut();
mark_style_dirty(dom, &mut state, id);
}
}
struct Shim {
inner: Rc<RefCell<DirtyState>>,
}
impl MutationObserver<TuiExt> for Shim {
fn observe(&mut self, dom: &mut Dom<TuiExt>, record: &Mutation) {
let mut state = self.inner.borrow_mut();
state.records = state.records.wrapping_add(1);
match record {
Mutation::AttributeChanged { id, name, .. } => {
mark_state_dirty(dom, &mut state, *id, Cause::Attribute(name));
}
Mutation::ClassChanged { id, .. } => {
mark_state_dirty(dom, &mut state, *id, Cause::Attribute("class"));
}
Mutation::ChildListChanged {
parent,
added,
removed,
..
} => {
for a in added {
mark_style_dirty(dom, &mut state, *a);
}
if state.sibling_marked.insert(*parent) {
let sibling_ids: Vec<NodeId> =
dom.node(*parent).children().map(|n| n.id()).collect();
for sib in sibling_ids {
mark_style_dirty(dom, &mut state, sib);
}
}
let any_text_added = added
.iter()
.any(|&n| dom.node(n).node_type() == rdom_core::NodeType::Text);
let any_text_removed = removed
.iter()
.any(|&n| dom.node(n).node_type() == rdom_core::NodeType::Text);
let text_changed = any_text_added || any_text_removed;
if text_changed {
state.paint_dirty = true;
}
let emptiness_may_flip = dom
.node(*parent)
.child_nodes()
.filter(|c| match c.node_type() {
rdom_core::NodeType::Element => true,
rdom_core::NodeType::Text => c.node_value().is_some_and(|t| !t.is_empty()),
_ => false,
})
.nth(added.len())
.is_none();
if emptiness_may_flip || text_changed {
mark_state_dirty(dom, &mut state, *parent, Cause::State);
}
if text_changed {
mark_placeholder_hosts(dom, &mut state, *parent);
}
}
Mutation::CharacterDataChanged { id, old, new } => {
if old.is_empty() != new.is_empty()
&& let Some(parent) = dom.node(*id).parent_node().map(|p| p.id())
{
mark_state_dirty(dom, &mut state, parent, Cause::State);
mark_placeholder_hosts(dom, &mut state, parent);
}
state.paint_dirty = true;
}
Mutation::InteractionChanged { prev, next, kind } => {
crate::rdom_trace!(
"DirtyTracker::observe InteractionChanged kind={kind:?} prev={prev:?} next={next:?}; \
marking style_dirty + pushing roots"
);
match kind {
InteractionKind::Hover | InteractionKind::Focus | InteractionKind::Active => {
mark_chain_change(dom, &mut state, *prev, *next);
}
_ => {
for id in [prev, next].into_iter().flatten() {
mark_state_dirty(dom, &mut state, *id, Cause::State);
}
}
}
crate::rdom_trace!(
"DirtyTracker::observe InteractionChanged: roots now = {:?}",
state.roots
);
}
Mutation::SelectionChanged { .. } => {
state.selection_dirty = true;
}
Mutation::PreDetach { .. } => {
}
other => {
debug_assert!(false, "DirtyTracker: unhandled mutation {other:?}");
state.paint_dirty = true;
}
}
}
}
fn mark_placeholder_hosts(dom: &mut Dom<TuiExt>, state: &mut DirtyState, from: NodeId) {
let mut cur = Some(from);
while let Some(id) = cur {
let node = dom.node(id);
cur = node.parent_node().map(|p| p.id());
if node
.get_attribute("placeholder")
.is_some_and(|v| !v.is_empty())
{
mark_state_dirty(dom, state, id, Cause::State);
}
}
}
fn mark_chain_change(
dom: &mut Dom<TuiExt>,
state: &mut DirtyState,
prev: Option<NodeId>,
next: Option<NodeId>,
) {
let chain = |dom: &Dom<TuiExt>, from: Option<NodeId>| {
let mut chain: Vec<NodeId> = Vec::new();
let mut cur = from;
while let Some(id) = cur {
let node = dom.node(id);
if node.ext().is_some() {
chain.push(id);
}
cur = node.parent_node().map(|p| p.id());
}
chain
};
let mut old = chain(dom, prev);
let mut new = chain(dom, next);
while !old.is_empty() && old.last() == new.last() {
old.pop();
new.pop();
}
for top in [old.last(), new.last()].into_iter().flatten() {
mark_state_dirty(dom, state, *top, Cause::State);
}
}
fn mark_state_dirty(dom: &mut Dom<TuiExt>, state: &mut DirtyState, id: NodeId, cause: Cause<'_>) {
mark_style_dirty(dom, state, id);
if !state.siblings.fires(cause) {
return;
}
let Some(parent) = dom.node(id).parent_node().map(|p| p.id()) else {
return;
};
if !state.sibling_marked.insert(parent) {
return;
}
let mut sib = dom.node(parent).first_element_child().map(|c| c.id());
while let Some(s) = sib {
mark_style_dirty(dom, state, s);
sib = dom.node(s).next_element_sibling().map(|c| c.id());
}
}
pub fn uses_sibling_combinators(sheet: &crate::style::Stylesheet) -> bool {
sheet
.rules()
.iter()
.any(|r| r.selector.0.iter().any(complex_uses_siblings))
}
fn complex_uses_siblings(c: &rdom_core::selectors::ComplexSelector) -> bool {
use rdom_core::selectors::{Combinator, SimpleSelector};
c.ancestors.iter().any(|(comb, _)| {
matches!(
comb,
Combinator::AdjacentSibling | Combinator::GeneralSibling
)
}) || std::iter::once(&c.subject)
.chain(c.ancestors.iter().map(|(_, compound)| compound))
.flat_map(|compound| &compound.simples)
.any(|s| match s {
SimpleSelector::Not(list) | SimpleSelector::Where(list) => {
list.0.iter().any(complex_uses_siblings)
}
_ => false,
})
}
fn mark_style_dirty(dom: &mut Dom<TuiExt>, state: &mut DirtyState, id: NodeId) {
if dom.node(id).ext().is_none() {
return;
}
let mut ancestor_dirty = false;
let mut cur = dom.node(id).parent_node().map(|p| p.id());
while let Some(a) = cur {
if state.roots_set.contains(&a) {
ancestor_dirty = true;
break;
}
cur = dom.node(a).parent_node().map(|p| p.id());
}
if let Some(ext) = dom.node_mut(id).ext_mut() {
ext.style_dirty = true;
}
if !ancestor_dirty {
if state.roots_set.insert(id) {
state.roots.push(id);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Color, TuiDom, TuiNodeExt, TuiNodeMutExt, TuiStyle};
#[test]
fn text_emptiness_flips_dirty_the_placeholder_host() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let ta = dom.create_element("textarea");
dom.set_attribute(ta, "placeholder", "p").unwrap();
let t = dom.create_text_node("");
dom.append_child(ta, t).unwrap();
dom.append_child(root, ta).unwrap();
let plain = dom.create_element("p");
let pt = dom.create_text_node("");
dom.append_child(plain, pt).unwrap();
dom.append_child(root, plain).unwrap();
let tracker = DirtyTracker::install(&mut dom);
tracker.set_sibling_combinators(false);
dom.node_mut(t).set_node_value("a").unwrap();
assert_eq!(tracker.take_roots(), vec![ta], "empty → text");
dom.node_mut(ta).ext_mut().unwrap().style_dirty = false;
assert!(tracker.take_paint_dirty());
dom.node_mut(t).set_node_value("ab").unwrap();
assert!(tracker.take_roots().is_empty(), "text → text: no restyle");
assert!(tracker.take_paint_dirty(), "but a relayout");
dom.node_mut(t).set_node_value("").unwrap();
assert_eq!(tracker.take_roots(), vec![ta], "text → empty");
dom.node_mut(pt).set_node_value("x").unwrap();
assert_eq!(tracker.take_roots(), vec![plain], "the parent's :empty");
dom.node_mut(plain).ext_mut().unwrap().style_dirty = false;
dom.node_mut(pt).set_node_value("xy").unwrap();
assert!(tracker.take_roots().is_empty(), "text → text: no restyle");
}
#[test]
fn a_state_change_dirties_siblings_only_while_sibling_combinators_may_apply() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let a = dom.create_element("div");
let b = dom.create_element("p");
dom.append_child(root, a).unwrap();
dom.append_child(root, b).unwrap();
let tracker = DirtyTracker::install(&mut dom);
dom.set_attribute(a, "x", "1").unwrap();
let roots = tracker.take_roots();
assert!(roots.contains(&a) && roots.contains(&b), "{roots:?}");
for id in [a, b] {
dom.node_mut(id).ext_mut().unwrap().style_dirty = false;
}
tracker.set_sibling_combinators(false);
dom.set_attribute(a, "x", "2").unwrap();
assert_eq!(tracker.take_roots(), vec![a]);
}
#[test]
fn sibling_combinators_are_found_anywhere_in_a_selector() {
use crate::style::Stylesheet;
let uses = |sel: &str| {
uses_sibling_combinators(&Stylesheet::bare().rule_unchecked(sel, TuiStyle::new()))
};
assert!(uses("a + b"));
assert!(uses("a ~ b c"));
assert!(uses("p:not(a + b)"));
assert!(uses(":where(a ~ b) > c"));
assert!(!uses("a > b c"));
assert!(!uses("p:not(.x)"));
assert!(
!uses_sibling_combinators(&Stylesheet::new()),
"the UA sheet has none"
);
}
#[test]
fn sibling_marking_is_once_per_parent_per_drain_and_rearms_after_drain() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let list = dom.create_element("ul");
dom.append_child(root, list).unwrap();
let first = dom.create_element("li");
dom.append_child(list, first).unwrap();
let tracker = DirtyTracker::install(&mut dom);
let mut items = Vec::new();
for _ in 0..2000 {
let li = dom.create_element("li");
dom.append_child(list, li).unwrap();
items.push(li);
}
for &li in &items {
assert!(dom.node(li).ext().unwrap().style_dirty);
}
let roots = tracker.take_roots();
assert_eq!(
roots.len(),
2001,
"each appended child is its own root, plus the sibling marked once"
);
assert_eq!(
roots.iter().collect::<std::collections::HashSet<_>>().len(),
2001,
"no duplicate roots"
);
for &li in &items {
dom.node_mut(li).ext_mut().unwrap().style_dirty = false;
}
let extra = dom.create_element("li");
dom.append_child(list, extra).unwrap();
assert!(
dom.node(items[0]).ext().unwrap().style_dirty,
"siblings re-marked after drain"
);
assert!(dom.node(extra).ext().unwrap().style_dirty);
}
#[test]
fn install_returns_tracker() {
let mut dom: TuiDom = TuiDom::new();
let tracker = DirtyTracker::install(&mut dom);
assert!(tracker.observer_id().is_some());
assert_eq!(dom.observer_count(), 1);
}
#[test]
fn uninstall_removes_observer() {
let mut dom: TuiDom = TuiDom::new();
let tracker = DirtyTracker::install(&mut dom);
let _roots = tracker.uninstall(&mut dom);
assert_eq!(dom.observer_count(), 0);
}
#[test]
fn set_attribute_marks_dirty() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let div = dom.create_element("div");
dom.append_child(root, div).unwrap();
let tracker = DirtyTracker::install(&mut dom);
dom.set_attribute(div, "id", "main").unwrap();
let roots = tracker.take_roots();
assert!(roots.contains(&div));
assert!(dom.node(div).ext().unwrap().style_dirty);
}
#[test]
fn add_class_marks_dirty() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let div = dom.create_element("div");
dom.append_child(root, div).unwrap();
let tracker = DirtyTracker::install(&mut dom);
dom.add_class(div, "active").unwrap();
assert!(tracker.take_roots().contains(&div));
}
#[test]
fn tree_mutation_marks_subtree_and_siblings() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let parent = dom.create_element("div");
let a = dom.create_element("a");
let b = dom.create_element("b");
dom.append_child(parent, a).unwrap();
dom.append_child(parent, b).unwrap();
dom.append_child(root, parent).unwrap();
let tracker = DirtyTracker::install(&mut dom);
let c = dom.create_element("c");
dom.append_child(parent, c).unwrap();
let roots = tracker.roots_snapshot();
assert!(roots.contains(&c));
}
#[test]
fn hover_changes_mark_both_prev_and_next() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let a = dom.create_element("a");
let b = dom.create_element("b");
dom.append_child(root, a).unwrap();
dom.append_child(root, b).unwrap();
let tracker = DirtyTracker::install(&mut dom);
dom.set_hovered(Some(a));
let roots1 = tracker.take_roots();
assert!(roots1.contains(&a));
dom.set_hovered(Some(b));
let roots2 = tracker.take_roots();
assert!(roots2.contains(&a));
assert!(roots2.contains(&b));
}
fn settle(dom: &mut TuiDom, tracker: &DirtyTracker, nodes: [NodeId; 4]) {
tracker.take_roots();
for id in std::iter::once(dom.root()).chain(nodes) {
if let Some(ext) = dom.node_mut(id).ext_mut() {
ext.style_dirty = false;
}
}
}
fn hover_chain() -> (TuiDom, DirtyTracker, [NodeId; 4]) {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let ul = dom.create_element("ul");
let li = dom.create_element("li");
let s1 = dom.create_element("span");
let s2 = dom.create_element("span");
dom.append_child(root, ul).unwrap();
dom.append_child(ul, li).unwrap();
dom.append_child(li, s1).unwrap();
dom.append_child(li, s2).unwrap();
let tracker = DirtyTracker::install(&mut dom);
tracker.set_sibling_combinators(false);
(dom, tracker, [ul, li, s1, s2])
}
fn dirty(dom: &TuiDom, id: NodeId) -> bool {
dom.node(id).ext().is_some_and(|e| e.style_dirty)
}
fn covered(dom: &TuiDom, roots: &[NodeId], id: NodeId) -> bool {
let mut cur = Some(id);
while let Some(n) = cur {
if roots.contains(&n) {
return true;
}
cur = dom.node(n).parent_node().map(|p| p.id());
}
false
}
#[test]
fn hovering_a_child_restyles_its_ancestor_chain() {
let (mut dom, tracker, [ul, li, s1, s2]) = hover_chain();
dom.set_hovered(Some(s1));
let roots = tracker.take_roots();
assert_eq!(roots, vec![ul], "the topmost element that became hovered");
assert!([ul, li, s1].iter().all(|&id| covered(&dom, &roots, id)));
assert!(!dirty(&dom, s2), "a sibling is not marked on its own");
}
#[test]
fn moving_between_siblings_restyles_only_the_unshared_chain() {
let (mut dom, tracker, nodes @ [ul, li, s1, s2]) = hover_chain();
dom.set_hovered(Some(s1));
settle(&mut dom, &tracker, nodes);
dom.set_hovered(Some(s2));
assert_eq!(tracker.take_roots(), vec![s1, s2]);
assert!(
!dirty(&dom, li) && !dirty(&dom, ul),
"the shared chain keeps :hover"
);
}
#[test]
fn moving_from_a_child_to_its_parent_restyles_only_the_child() {
let (mut dom, tracker, nodes @ [_, li, s1, _]) = hover_chain();
dom.set_hovered(Some(s1));
settle(&mut dom, &tracker, nodes);
dom.set_hovered(Some(li));
assert_eq!(tracker.take_roots(), vec![s1]);
assert!(!dirty(&dom, li));
}
#[test]
fn leaving_restyles_the_whole_old_chain() {
let (mut dom, tracker, nodes @ [ul, li, s1, _]) = hover_chain();
dom.set_hovered(Some(s1));
settle(&mut dom, &tracker, nodes);
dom.set_hovered(None);
let roots = tracker.take_roots();
assert_eq!(roots, vec![ul]);
assert!(covered(&dom, &roots, li) && covered(&dom, &roots, s1));
}
#[test]
fn active_changes_restyle_the_unshared_chain() {
let (mut dom, tracker, nodes @ [ul, li, s1, s2]) = hover_chain();
dom.set_active(Some(s1));
assert_eq!(tracker.take_roots(), vec![ul]);
settle(&mut dom, &tracker, nodes);
dom.set_active(Some(s2));
assert_eq!(tracker.take_roots(), vec![s1, s2]);
assert!(!dirty(&dom, li));
settle(&mut dom, &tracker, nodes);
dom.set_active(None);
assert_eq!(tracker.take_roots(), vec![ul]);
}
#[test]
fn removing_the_hovered_element_restyles_its_old_ancestors() {
let (mut dom, tracker, nodes @ [_, li, s1, _]) = hover_chain();
dom.set_hovered(Some(s1));
settle(&mut dom, &tracker, nodes);
dom.remove_child(li, s1).unwrap();
let roots = tracker.take_roots();
assert!(
covered(&dom, &roots, li),
"li no longer contains the hovered element: {roots:?}"
);
}
#[test]
fn focus_changes_mark_prev_and_next() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let a = dom.create_element("a");
let b = dom.create_element("b");
dom.append_child(root, a).unwrap();
dom.append_child(root, b).unwrap();
let tracker = DirtyTracker::install(&mut dom);
dom.set_focused(Some(a));
dom.set_focused(Some(b));
let roots = tracker.take_roots();
assert!(roots.contains(&a));
assert!(roots.contains(&b));
}
#[test]
fn focus_changes_dirty_ancestor_chain_for_focus_within() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let outer = dom.create_element("div");
let middle = dom.create_element("div");
let inner = dom.create_element("span");
dom.append_child(middle, inner).unwrap();
dom.append_child(outer, middle).unwrap();
dom.append_child(root, outer).unwrap();
let tracker = DirtyTracker::install(&mut dom);
dom.set_focused(Some(inner));
let roots = tracker.take_roots();
let outer_dirty =
roots.contains(&outer) || dom.node(outer).ext().is_some_and(|e| e.style_dirty);
assert!(
outer_dirty,
"outer must re-cascade so its :focus-within match flips when inner gets focus"
);
}
#[test]
fn dedup_with_dirty_ancestor() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let parent = dom.create_element("div");
let child = dom.create_element("span");
dom.append_child(parent, child).unwrap();
dom.append_child(root, parent).unwrap();
let tracker = DirtyTracker::install(&mut dom);
dom.set_attribute(parent, "role", "banner").unwrap();
dom.set_attribute(child, "id", "x").unwrap();
let roots = tracker.take_roots();
assert!(roots.contains(&parent));
assert!(!roots.contains(&child));
assert!(dom.node(child).ext().unwrap().style_dirty);
}
#[test]
fn take_roots_clears_list() {
let mut dom: TuiDom = TuiDom::new();
let div = dom.create_element("div");
dom.append_child(dom.root(), div).unwrap();
let tracker = DirtyTracker::install(&mut dom);
dom.set_attribute(div, "x", "1").unwrap();
assert!(!tracker.take_roots().is_empty());
assert!(tracker.take_roots().is_empty());
}
#[test]
fn roots_snapshot_does_not_clear() {
let mut dom: TuiDom = TuiDom::new();
let div = dom.create_element("div");
dom.append_child(dom.root(), div).unwrap();
let tracker = DirtyTracker::install(&mut dom);
dom.set_attribute(div, "x", "1").unwrap();
let s1 = tracker.roots_snapshot();
let s2 = tracker.roots_snapshot();
assert_eq!(s1, s2);
}
#[test]
fn character_data_change_does_not_dirty_cascade_but_flags_paint() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let t = dom.create_text_node("hello");
dom.append_child(root, t).unwrap();
let tracker = DirtyTracker::install(&mut dom);
dom.node_mut(t).set_node_value("world").unwrap();
assert!(tracker.roots_snapshot().is_empty());
assert!(tracker.paint_dirty_snapshot());
}
#[test]
fn take_paint_dirty_clears_flag() {
let mut dom: TuiDom = TuiDom::new();
let t = dom.create_text_node("hi");
dom.append_child(dom.root(), t).unwrap();
let tracker = DirtyTracker::install(&mut dom);
dom.node_mut(t).set_node_value("ho").unwrap();
assert!(tracker.take_paint_dirty());
assert!(!tracker.take_paint_dirty());
}
#[test]
fn set_hovered_to_same_does_not_dirty() {
let mut dom: TuiDom = TuiDom::new();
let a = dom.create_element("a");
dom.append_child(dom.root(), a).unwrap();
dom.set_hovered(Some(a));
let tracker = DirtyTracker::install(&mut dom);
dom.set_hovered(Some(a));
assert!(tracker.take_roots().is_empty());
}
#[test]
fn duplicate_dirty_is_deduplicated() {
let mut dom: TuiDom = TuiDom::new();
let div = dom.create_element("div");
dom.append_child(dom.root(), div).unwrap();
let tracker = DirtyTracker::install(&mut dom);
dom.set_attribute(div, "x", "1").unwrap();
dom.set_attribute(div, "y", "2").unwrap();
dom.set_attribute(div, "z", "3").unwrap();
let roots = tracker.take_roots();
assert_eq!(roots.iter().filter(|&&r| r == div).count(), 1);
}
#[test]
fn inline_style_setter_marks_dirty() {
let mut dom: TuiDom = TuiDom::new();
let div = dom.create_element("div");
dom.append_child(dom.root(), div).unwrap();
let tracker = DirtyTracker::install(&mut dom);
dom.node_mut(div)
.set_inline_style(TuiStyle::new().fg(Color::Rgb(255, 0, 0)));
assert_eq!(tracker.take_roots(), vec![div]);
}
#[test]
fn an_unqueued_dirty_flag_above_does_not_swallow_a_root() {
let mut dom: TuiDom = TuiDom::new();
let outer = dom.create_element("div");
let inner = dom.create_element("div");
dom.append_child(dom.root(), outer).unwrap();
dom.append_child(outer, inner).unwrap();
let tracker = DirtyTracker::install(&mut dom);
dom.node_mut(outer).ext_mut().unwrap().style_dirty = true;
dom.set_attribute(inner, "data-x", "1").unwrap();
assert!(tracker.take_roots().contains(&inner));
}
#[test]
fn mark_dirty_escape_hatch() {
let mut dom: TuiDom = TuiDom::new();
let div = dom.create_element("div");
dom.append_child(dom.root(), div).unwrap();
let tracker = DirtyTracker::install(&mut dom);
dom.node_mut(div)
.set_inline_style(TuiStyle::new().fg(Color::Rgb(255, 0, 0)));
tracker.mark_dirty(&mut dom, div);
assert!(tracker.take_roots().contains(&div));
assert!(dom.node(div).is_style_dirty());
}
}