rdom-tui 0.3.3

Terminal rendering layer for rdom-core — flexbox layout, TUI styles, key/mouse events. Use rdom-core directly for headless DOM manipulation.
Documentation
//! Implicit-detach event dispatch.
//!
//! When the focused or hovered element is detached from the tree
//! (directly or because an ancestor is being removed), browsers
//! dispatch a small ceremony of events:
//!
//! - **Focus loss:** `blur` (non-bubbling) + `focusout` (bubbles)
//!   on the previously-focused element.
//! - **Hover loss:** `mouseout` (bubbles) + `mouseleave` (non-
//!   bubbling) on the previously-hovered element.
//!
//! These events fire BEFORE the actual removal in the DOM event
//! model, so bubbling works through the still-intact ancestor
//! chain. `rdom-core::tree::detach_from_parent` emits a
//! `Mutation::PreDetach` record before structural unlink for
//! exactly this hook: this module installs an observer that
//! listens for `PreDetach` and dispatches the appropriate events
//! via the normal `TuiDispatchExt` pipeline, which still walks
//! the live parent chain at the moment of the observer callback.
//!
//! Installed once from `App::with_backend`. No public surface — the
//! observer is opaque; consumers register their own `blur`/
//! `focusout`/`mouseout`/`mouseleave` listeners on whichever
//! nodes they care about and get fired automatically.
//!
//! ## Reentrancy notes for listener authors
//!
//! Handlers run DURING `detach_from_parent`, in the window between
//! the `PreDetach` mutation record firing and the structural
//! unlink. The tree is still intact at that moment. Implications:
//!
//! - **Mutating the tree from a handler is allowed.** Setting
//!   focus elsewhere, appending nodes, removing siblings — all
//!   work.
//! - **Re-attaching the about-to-be-detached subtree does NOT
//!   cancel the in-flight detach.** After every handler in the
//!   ceremony returns, `detach_from_parent` proceeds with the
//!   structural unlink of the original target. A handler that
//!   appends the focused subtree to a different parent will
//!   see that re-attachment torn out again immediately.
//! - **A handler that calls `dom.set_focused(another_node)`
//!   during `blur`** works as expected — the second
//!   `InteractionChanged` record fires the cascade pickup; the
//!   purge step then sees that `focused` is no longer in the
//!   subtree and skips clearing it.

use rdom_core::Mutation;

use crate::{TuiDispatchExt, TuiDom, TuiEvent};

/// Observer that translates `Mutation::PreDetach` records into
/// the implicit DOM events browsers fire when the focused or
/// hovered element is removed.
pub(crate) struct ImplicitDetachEvents;

impl rdom_core::MutationObserver<crate::TuiExt> for ImplicitDetachEvents {
    fn observe(&mut self, dom: &mut TuiDom, record: &Mutation) {
        let Mutation::PreDetach {
            detached_root: _,
            focused,
            hovered,
        } = record
        else {
            return;
        };
        // Focus loss ceremony — `blur` non-bubbling, `focusout`
        // bubbling. The order is browser-faithful: blur first,
        // then focusout. Both fire on the same target; the
        // bubbling difference is on the event itself.
        if let Some(target) = focused {
            // `blur`: non-bubbling, non-cancelable per UI Events.
            let mut blur = TuiEvent::new("blur");
            blur.event.bubbles = false;
            blur.event.cancelable = false;
            blur.event = blur.event.clone().with_synthetic(true);
            let _ = dom.dispatch_tui_event(*target, &mut blur);

            // `focusout`: bubbles, non-cancelable per UI Events.
            let mut focusout = TuiEvent::new("focusout");
            focusout.event.cancelable = false;
            focusout.event = focusout.event.clone().with_synthetic(true);
            let _ = dom.dispatch_tui_event(*target, &mut focusout);
        }
        // Hover loss ceremony — `mouseout` bubbling, `mouseleave`
        // non-bubbling. Order: mouseout first (matches browser).
        if let Some(target) = hovered {
            // `mouseout`: bubbles, cancelable per UI Events.
            let mut mouseout = TuiEvent::new("mouseout");
            mouseout.event = mouseout.event.clone().with_synthetic(true);
            let _ = dom.dispatch_tui_event(*target, &mut mouseout);

            // `mouseleave`: non-bubbling, non-cancelable per UI Events.
            let mut mouseleave = TuiEvent::new("mouseleave");
            mouseleave.event.bubbles = false;
            mouseleave.event.cancelable = false;
            mouseleave.event = mouseleave.event.clone().with_synthetic(true);
            let _ = dom.dispatch_tui_event(*target, &mut mouseleave);
        }
    }
}

/// Install the implicit-detach observer on `dom`. Called once
/// during `App::with_backend`.
pub(crate) fn install(dom: &mut TuiDom) {
    dom.add_mutation_observer(Box::new(ImplicitDetachEvents));
}