use std::cell::RefCell;
use std::rc::Rc;
use rdom_core::{Dom, InteractionKind, Mutation, MutationObserver, NodeId, ObserverId};
use crate::ext::TuiExt;
#[derive(Debug, Clone, Default)]
pub struct DirtyTracker {
inner: Rc<RefCell<DirtyState>>,
observer_id: Option<ObserverId>,
}
#[derive(Debug, Default)]
struct DirtyState {
roots: Vec<NodeId>,
paint_dirty: bool,
}
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);
}
std::mem::take(&mut self.inner.borrow_mut().roots)
}
pub fn take_roots(&self) -> Vec<NodeId> {
std::mem::take(&mut self.inner.borrow_mut().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 observer_id(&self) -> Option<ObserverId> {
self.observer_id
}
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();
match record {
Mutation::AttributeChanged { id, .. } | Mutation::ClassChanged { id, .. } => {
mark_style_dirty(dom, &mut state, *id);
}
Mutation::ChildListChanged {
parent,
added,
removed,
..
} => {
for a in added {
mark_style_dirty(dom, &mut state, *a);
}
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);
if any_text_added || any_text_removed {
state.paint_dirty = true;
}
}
Mutation::CharacterDataChanged { .. } => {
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"
);
if let Some(p) = prev {
mark_style_dirty(dom, &mut state, *p);
}
if let Some(n) = next {
mark_style_dirty(dom, &mut state, *n);
}
if matches!(kind, InteractionKind::Focus) {
if let Some(p) = prev {
mark_ancestor_chain_style_dirty(dom, &mut state, *p);
}
if let Some(n) = next {
mark_ancestor_chain_style_dirty(dom, &mut state, *n);
}
}
crate::rdom_trace!(
"DirtyTracker::observe InteractionChanged: roots now = {:?}",
state.roots
);
}
Mutation::SelectionChanged { .. } => {
}
Mutation::PreDetach { .. } => {
}
}
}
}
fn mark_ancestor_chain_style_dirty(dom: &mut Dom<TuiExt>, state: &mut DirtyState, id: NodeId) {
let mut chain: Vec<NodeId> = Vec::new();
let mut cur = dom.node(id).parent_node().map(|p| p.id());
while let Some(a) = cur {
if dom.node(a).ext().is_some() {
chain.push(a);
}
cur = dom.node(a).parent_node().map(|p| p.id());
}
for ancestor in chain.into_iter().rev() {
mark_style_dirty(dom, state, ancestor);
}
}
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 dom.node(a).ext().is_some_and(|e| e.style_dirty) {
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.contains(&id) {
state.roots.push(id);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Color, TuiDom, TuiNodeExt, TuiNodeMutExt, TuiStyle};
#[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));
}
#[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!(tracker.take_roots().is_empty());
}
#[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());
}
}