htui 0.1.0

A terminal HTML/CSS renderer with an embedded JavaScript engine — build interactive TUIs from HTML, CSS and JS
Documentation
use std::sync::Arc;

use indexmap::IndexMap;

use crate::html::css_parse::DynamicState;
use crate::tdom::node::NodeId;

/// Shape of the page text selection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectionMode {
    /// Left-drag: continuous text between the anchor and the current point,
    /// spanning whole lines in between (browser-like reading order).
    Flow,
    /// Right-drag: rectangular column selection (vim `Ctrl+V` style).
    Block,
}

/// DOM mutations to repaint on the next `RenderArena` frame.
///
/// The script engine records the session-local node ids whose content or
/// attributes JS changed since the last render. When the set is non-empty the
/// render thread can try a region-limited repaint (re-layout the tree, then
/// repaint only the affected rectangles) instead of clearing and re-painting
/// the whole canvas; the rect comparison against the cached layout decides
/// whether that path is safe and falls back to a full repaint otherwise.
/// `injected_css` carries the live injected-CSS set (see the field docs) so
/// the render thread can rebuild the session stylesheet before re-cascading.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DirtyInfo {
    pub nodes: Vec<crate::tdom::node::NodeId>,
    /// CSS extracted from `<style>` / `<link rel=stylesheet>` elements
    /// inserted through `innerHTML`, keyed by the host element that received
    /// the write. Rewriting a host *replaces* its entry — the browser drops
    /// the host's old `<style>` elements — so this set tracks the document's
    /// live injected styles instead of accumulating an unbounded history.
    pub injected_css: IndexMap<crate::tdom::node::NodeId, String>,
}

impl DirtyInfo {
    /// True when no DOM mutation is pending (nothing to repaint).
    pub fn is_empty(&self) -> bool {
        self.nodes.is_empty() && self.injected_css.is_empty()
    }
}

#[derive(Debug, Clone)]
pub enum RenderCmd {
    Rerender {
        scroll_y: u32,
    },
    Redraw {
        scroll_y: u32,
        zoom: f32,
    },
    /// Render a supplied DOM arena snapshot (the script engine's live arena
    /// after JS mutations), cascading styles and re-laying-out without
    /// re-parsing HTML. `dynamic` carries the session interaction state (the
    /// `:hover` target and the focused field), so the re-cascade re-applies
    /// both highlights in the same pass (no separate `Hover` / `Focus`
    /// command needed). `dirty` lists the nodes JS mutated since the previous
    /// frame, letting the render thread repaint only the affected rectangles
    /// when the re-layout leaves every box rect unchanged.
    RenderArena {
        arena: Arc<crate::tdom::node::NodeArena>,
        root_id: crate::tdom::node::NodeId,
        scroll_y: u32,
        zoom: f32,
        dynamic: DynamicState,
        dirty: DirtyInfo,
    },
    /// Replace the session document with `html` and fully re-render it,
    /// keeping the current scroll position / zoom. Script-path uses are
    /// intercepted by `ScriptEngine::reload` so the engine re-parses the
    /// document (single source of truth); the render-thread branch only
    /// handles direct API use / fallback.
    RenderHtml {
        html: Arc<str>,
    },
    /// Re-style the cached document for the CSS `:hover` pseudo-class with the
    /// given session-local node (`None` clears any hover), then repaint. With
    /// no `:hover` rules in the stylesheet this repaints to the same pixels.
    Hover {
        node_id: Option<NodeId>,
    },
    /// Re-style the cached document for the CSS `:focus` pseudo-class with the
    /// given focused text field (`None` clears any focus highlight), then
    /// repaint. Pushed by the input loop whenever the focus moves; with no
    /// `:focus` rules in the stylesheet this repaints to the same pixels.
    Focus {
        node_id: Option<NodeId>,
    },
    /// Repaint one `<input>` field with fresh edit state (value / caret /
    /// focus). The layout is unchanged — the field's rectangle is fixed — so
    /// only that rectangle is repainted.
    RenderInput {
        node_id: NodeId,
        value: String,
        cursor: usize,
        focused: bool,
        selection: Option<(usize, usize)>,
    },
    /// JS `el.focus()` — focus the text field `node_id`. Intercepted by the
    /// input loop (never forwarded to the render thread): it seeds the edit
    /// session and routes the `blur` / `focus` events back through the
    /// script-message path, so the focus state stays owned by the input side.
    FocusRequest {
        node_id: NodeId,
    },
    /// JS `el.blur()` — remove focus from `node_id` if it is the focused
    /// field (no-op otherwise, matching the browser). Intercepted by the
    /// input loop like [`RenderCmd::FocusRequest`](Self::FocusRequest).
    BlurRequest {
        node_id: NodeId,
    },
    /// Open the dropdown overlay for `node_id`: the render thread resolves the
    /// options (a `<select>`'s children, or an `<input list>`'s `<datalist>`
    /// filtered by `filter`), paints the overlay below the field and
    /// recolours the field's frame. `filter` is `None` for a plain `<select>`.
    SelectOpen {
        node_id: NodeId,
        filter: Option<String>,
    },
    /// Close the dropdown overlay for `node_id` (clears the painted overlay
    /// and restores the field's frame).
    SelectClose {
        node_id: NodeId,
    },
    /// Move the dropdown's highlighted row to `highlight` (keyboard
    /// navigation) and repaint only the overlay.
    SelectMove {
        node_id: NodeId,
        highlight: usize,
    },
    /// Update the page text selection (canvas-space anchor and current
    /// endpoint; either `None` clears it). The render thread repaints the
    /// affected region with the selection highlight (flow or block shape) and
    /// refreshes the text extractable from it (read on `Ctrl+C` to copy).
    TextSelection {
        anchor: Option<(u32, u32)>,
        end: Option<(u32, u32)>,
        /// Selection shape; ignored when `anchor` / `end` are `None`.
        mode: SelectionMode,
    },
    Quit,
}

impl RenderCmd {
    /// Refresh the interaction state a `RenderArena` snapshot carries.
    ///
    /// The arena's `dynamic` is captured when its DOM snapshot is folded,
    /// while the focus is owned by the input loop and can move later in the
    /// same batch (`el.focus()` requests are expanded after the fold). Both
    /// the input loop (before sending) and the render thread's drain (when a
    /// `Focus` delta lands behind a pending arena) stamp the batch-final
    /// value through this method so the arena render paints the state the
    /// batch ends with. Non-arena commands are unaffected.
    pub fn refresh_dynamic_focus(&mut self, focused: Option<NodeId>) {
        if let RenderCmd::RenderArena { dynamic, .. } = self {
            dynamic.focused = focused;
        }
    }
}

/// One render thread's drained command batch, classified by merge semantics.
///
/// The channel accumulates commands across input-loop batches while the
/// render thread finishes the previous frame, so the drain must decide per
/// command class what survives:
///
/// - Stateful, must-run-in-order commands — [`RenderCmd::RenderInput`] (a
///   focus switch queues blur + focus for two fields; dropping either leaves
///   a stale caret) and the `Select*` family (they mutate the render
///   thread's dropdown state, so a `SelectClose` must not be dropped in
///   favour of the [`RenderCmd::RenderArena`] its `change` handler
///   triggers) — are kept in arrival order and all run.
/// - Snapshot commands (`Rerender` / `Redraw` / `RenderArena` /
///   `RenderHtml` / `Quit`) each carry a complete final state, so a later
///   one safely supersedes an earlier one; only the last of the batch is
///   kept.
/// - [`RenderCmd::TextSelection`] is *incremental*: its repaint region
///   derives from the previously applied selection, so superseding it with
///   a same-batch snapshot (a `RenderArena` triggered by a timer / hover
///   mid-drag) would leave the render thread re-applying the stale
///   highlight and make the next selection jump the edge forward — the
///   visible flicker. It is deferred instead and applied after the snapshot
///   command, on the fresh canvas.
/// - A [`RenderCmd::Hover`] arriving behind a pending `RenderArena` is
///   likewise deferred: superseding the arena render (whose
///   `dynamic.hovered` was captured when the DOM change was folded) would
///   drop the DOM mutations — an animated widget under the pointer (e.g.
///   the music demo's progress bar) would then update in irregular jumps.
///   The deferred hover repaint re-applies the newer highlight on top.
/// - A [`RenderCmd::Focus`] arriving behind a pending `RenderArena`
///   *merges into that arena's [`DynamicState`]*: superseding it would drop
///   the DOM mutations, and dropping it would freeze the `:focus`
///   highlight. The merged snapshot paints the DOM mutations and the
///   newest focus in the same pass.
#[derive(Debug, Default)]
pub struct DrainedBatch {
    inputs: Vec<RenderCmd>,
    selects: Vec<RenderCmd>,
    text_selection: Option<RenderCmd>,
    hover: Option<RenderCmd>,
    snapshot: Option<RenderCmd>,
    quit: bool,
}

impl DrainedBatch {
    /// Create an empty batch.
    pub fn new() -> Self {
        Self::default()
    }

    /// Classify and absorb one command — the batch's first (received with
    /// `recv`) or one drained afterwards (`try_recv`).
    pub fn absorb(&mut self, cmd: RenderCmd) {
        match cmd {
            RenderCmd::RenderInput { .. } => self.inputs.push(cmd),
            sel @ (RenderCmd::SelectOpen { .. }
            | RenderCmd::SelectClose { .. }
            | RenderCmd::SelectMove { .. }) => self.selects.push(sel),
            other @ RenderCmd::TextSelection { .. } => {
                // Only the latest selection matters; earlier ones in the
                // same batch are intermediates.
                self.text_selection = Some(other);
            }
            other @ RenderCmd::Hover { .. }
                if matches!(self.snapshot, Some(RenderCmd::RenderArena { .. })) =>
            {
                // The arena render re-cascades with the hover state it
                // carried; the newer hover repaint must not swallow the
                // DOM mutations, so defer it (see the type docs).
                self.hover = Some(other);
            }
            RenderCmd::Focus { node_id }
                if matches!(self.snapshot, Some(RenderCmd::RenderArena { .. })) =>
            {
                // Merge into the pending arena's interaction state: the
                // same pass paints the DOM mutations and the newest focus
                // (see the type docs).
                if let Some(snapshot) = self.snapshot.as_mut() {
                    snapshot.refresh_dynamic_focus(node_id);
                }
            }
            other => {
                self.quit |= matches!(other, RenderCmd::Quit);
                self.snapshot = Some(other);
            }
        }
    }

    /// `true` when a `Quit` was absorbed — draining may stop early, and the
    /// snapshot arm exits the render loop.
    pub fn quit_requested(&self) -> bool {
        self.quit
    }

    /// Drain the `RenderInput` commands in arrival order.
    pub fn drain_inputs(&mut self) -> impl Iterator<Item = RenderCmd> {
        std::mem::take(&mut self.inputs).into_iter()
    }

    /// Drain the `Select*` commands in arrival order.
    pub fn drain_selects(&mut self) -> impl Iterator<Item = RenderCmd> {
        std::mem::take(&mut self.selects).into_iter()
    }

    /// Take the deferred page text selection (the latest of the batch).
    pub fn take_text_selection(&mut self) -> Option<RenderCmd> {
        self.text_selection.take()
    }

    /// Take the snapshot command the batch ends with.
    pub fn take_snapshot(&mut self) -> Option<RenderCmd> {
        self.snapshot.take()
    }

    /// Take the deferred `Hover` (re-applied after the snapshot render).
    pub fn take_hover(&mut self) -> Option<RenderCmd> {
        self.hover.take()
    }
}