oxi-tui 0.60.0

Terminal UI rendering pipeline and widget library for oxi (v2 — terminal-first pipeline)
Documentation
//! Primitive widgets — `Text` (single-line / multi-line text), `Border`
//! (bordered box with optional title), `List<T>` (virtualized list of
//! `Renderable` items), and `Scrollbar` (vertical scroll position
//! indicator).
//!
//! See `Renderable` for the shared contract; each primitive is a small
//! `Renderable` that wraps either a ratatui widget (`Block`, `Scrollbar`)
//! or a `Vec<RetainedChild<T>>` and forwards the `Renderable` lifecycle.

pub mod border;
pub mod list;
pub mod scrollbar;

pub use border::Border;
pub use list::List;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
pub use scrollbar::Scrollbar;

use crate::widget::{RenderCtx, Renderable, hash_str};

#[derive(Debug, Clone)]
pub struct Text {
    content: String,
    style: Style,
    cached_hash: u64,
}

impl Text {
    pub fn new(content: impl Into<String>) -> Self {
        let content = content.into();
        let cached_hash = hash_str(&content);
        Self {
            content,
            style: Style::default(),
            cached_hash,
        }
    }

    #[must_use]
    pub fn styled(mut self, style: Style) -> Self {
        self.style = style;
        self
    }

    #[must_use]
    pub fn fg(mut self, color: Color) -> Self {
        self.style = self.style.fg(color);
        self
    }

    #[must_use]
    pub fn bold(mut self) -> Self {
        self.style = self.style.add_modifier(Modifier::BOLD);
        self
    }

    pub fn set_content(&mut self, content: impl Into<String>) {
        let content = content.into();
        self.cached_hash = hash_str(&content);
        self.content = content;
    }
}

impl Renderable for Text {
    fn content_hash(&self) -> u64 {
        // Combine content hash with style bits for correctness
        let style_bits = u64::from(self.style.fg.is_some())
            | (u64::from(self.style.bg.is_some()) << 1)
            | (u64::from(self.style.add_modifier.bits()) << 2);
        crate::widget::hash_combine(self.cached_hash, style_bits)
    }

    fn height_for(&self, _width: u16, _ctx: &RenderCtx) -> u16 {
        u16::try_from(self.content.lines().count().max(1)).unwrap_or(u16::MAX)
    }

    fn render(&mut self, area: Rect, ctx: &mut RenderCtx) {
        let line = Line::from(vec![Span::styled(self.content.clone(), self.style)]);
        ctx.buffer_mut().set_line(area.x, area.y, &line, area.width);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn text_hash_changes_on_set_content() {
        let mut t = Text::new("hello");
        let h1 = t.content_hash();
        t.set_content("world");
        let h2 = t.content_hash();
        assert_ne!(h1, h2);
    }

    #[test]
    fn text_hash_stable_on_same_content() {
        let t1 = Text::new("hello");
        let t2 = Text::new("hello");
        assert_eq!(t1.content_hash(), t2.content_hash());
    }

    #[test]
    fn text_hash_differs_with_style() {
        let plain = Text::new("hi");
        let bold = Text::new("hi").bold();
        assert_ne!(plain.content_hash(), bold.content_hash());
    }
}