ftui-widgets 0.4.0

Widget library built on FrankenTUI render and layout.
Documentation
#![forbid(unsafe_code)]

//! Badge widget.
//!
//! A small, single-line label with background + foreground styling and
//! configurable left/right padding. Intended for "status", "priority", etc.
//!
//! Design goals:
//! - No per-render heap allocations (draws directly to the `Frame`)
//! - Deterministic output (stable padding + truncation)
//! - Tiny-area safe (0 width/height is a no-op)

use crate::{Widget, apply_style, clear_text_row, draw_text_span};
use ftui_core::geometry::Rect;
use ftui_render::cell::Cell;
use ftui_render::frame::Frame;
use ftui_style::Style;
use ftui_text::display_width;

/// A compact label with padding and style.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Badge<'a> {
    label: &'a str,
    style: Style,
    pad_left: u16,
    pad_right: u16,
}

impl<'a> Badge<'a> {
    /// Create a new badge with 1 cell padding on each side.
    #[must_use]
    pub fn new(label: &'a str) -> Self {
        Self {
            label,
            style: Style::default(),
            pad_left: 1,
            pad_right: 1,
        }
    }

    /// Set the badge style (foreground/background/attrs).
    #[must_use]
    pub fn with_style(mut self, style: Style) -> Self {
        self.style = style;
        self
    }

    /// Set the left/right padding in cells.
    #[must_use]
    pub fn with_padding(mut self, left: u16, right: u16) -> Self {
        self.pad_left = left;
        self.pad_right = right;
        self
    }

    /// Display width in terminal cells (label width + padding).
    #[inline]
    #[must_use]
    pub fn width(&self) -> u16 {
        let label_width = display_width(self.label) as u16;
        label_width
            .saturating_add(self.pad_left)
            .saturating_add(self.pad_right)
    }

    #[inline]
    fn render_spaces(
        frame: &mut Frame,
        mut x: u16,
        y: u16,
        n: u16,
        style: Style,
        max_x: u16,
    ) -> u16 {
        let mut cell = Cell::from_char(' ');
        apply_style(&mut cell, style);
        for _ in 0..n {
            if x >= max_x {
                break;
            }
            frame.buffer.set_fast(x, y, cell);
            x = x.saturating_add(1);
        }
        x
    }
}

impl Widget for Badge<'_> {
    fn render(&self, area: Rect, frame: &mut Frame) {
        if area.is_empty() {
            return;
        }

        let deg = frame.buffer.degradation;
        if !deg.render_content() {
            return;
        }

        let style = if deg.apply_styling() {
            self.style
        } else {
            Style::default()
        };

        let y = area.y;
        let max_x = area.right();
        let mut x = area.x;

        clear_text_row(frame, area, style);

        x = Self::render_spaces(frame, x, y, self.pad_left, style, max_x);
        x = draw_text_span(frame, x, y, self.label, style, max_x);
        let _ = Self::render_spaces(frame, x, y, self.pad_right, style, max_x);
    }

    fn is_essential(&self) -> bool {
        false
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use ftui_render::budget::DegradationLevel;
    use ftui_render::cell::PackedRgba;
    use ftui_render::grapheme_pool::GraphemePool;

    #[test]
    fn width_includes_padding() {
        let badge = Badge::new("OK");
        assert_eq!(badge.width(), 4);
        let badge = Badge::new("OK").with_padding(2, 3);
        assert_eq!(badge.width(), 7);
    }

    #[test]
    fn renders_padded_label_with_style() {
        let style = Style::new()
            .fg(PackedRgba::rgb(1, 2, 3))
            .bg(PackedRgba::rgb(4, 5, 6));
        let badge = Badge::new("OK").with_style(style);

        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(10, 1, &mut pool);
        badge.render(Rect::new(0, 0, 10, 1), &mut frame);

        let expected = [' ', 'O', 'K', ' '];
        for (x, ch) in expected.into_iter().enumerate() {
            let cell = frame.buffer.get(x as u16, 0).unwrap();
            assert_eq!(cell.content.as_char(), Some(ch));
            assert_eq!(cell.fg, PackedRgba::rgb(1, 2, 3));
            assert_eq!(cell.bg, PackedRgba::rgb(4, 5, 6));
        }
    }

    #[test]
    fn truncates_in_small_area() {
        let style = Style::new()
            .fg(PackedRgba::rgb(1, 2, 3))
            .bg(PackedRgba::rgb(4, 5, 6));
        let badge = Badge::new("OK").with_style(style);

        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(2, 1, &mut pool);
        badge.render(Rect::new(0, 0, 2, 1), &mut frame);

        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some(' '));
        assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('O'));
    }

    #[test]
    fn default_padding_is_one() {
        let badge = Badge::new("X");
        // "X" is 1 wide + 1 left + 1 right = 3
        assert_eq!(badge.width(), 3);
    }

    #[test]
    fn zero_padding() {
        let badge = Badge::new("AB").with_padding(0, 0);
        assert_eq!(badge.width(), 2);
    }

    #[test]
    fn empty_label_width() {
        let badge = Badge::new("");
        // 0 label + 1 left + 1 right = 2
        assert_eq!(badge.width(), 2);
    }

    #[test]
    fn render_empty_area_is_noop() {
        let badge = Badge::new("Test");
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(10, 1, &mut pool);
        badge.render(Rect::new(0, 0, 0, 0), &mut frame);
        // Should not panic
    }

    #[test]
    fn is_not_essential() {
        let badge = Badge::new("OK");
        assert!(!badge.is_essential());
    }

    #[test]
    fn render_no_styling_drops_configured_style() {
        let style = Style::new()
            .fg(PackedRgba::rgb(1, 2, 3))
            .bg(PackedRgba::rgb(4, 5, 6));
        let badge = Badge::new("OK").with_style(style);
        let expected_badge = Badge::new("OK");

        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(10, 1, &mut pool);
        frame.buffer.degradation = DegradationLevel::NoStyling;
        badge.render(Rect::new(0, 0, 10, 1), &mut frame);

        let mut expected_pool = GraphemePool::new();
        let mut expected = Frame::new(10, 1, &mut expected_pool);
        expected_badge.render(Rect::new(0, 0, 10, 1), &mut expected);

        assert_eq!(frame.buffer.get(1, 0), expected.buffer.get(1, 0));
    }

    #[test]
    fn render_skeleton_is_noop() {
        let badge = Badge::new("OK").with_style(
            Style::new()
                .fg(PackedRgba::rgb(1, 2, 3))
                .bg(PackedRgba::rgb(4, 5, 6)),
        );

        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(10, 1, &mut pool);
        let mut expected_pool = GraphemePool::new();
        let expected = Frame::new(10, 1, &mut expected_pool);
        frame.buffer.degradation = DegradationLevel::Skeleton;
        badge.render(Rect::new(0, 0, 10, 1), &mut frame);

        for x in 0..10 {
            assert_eq!(frame.buffer.get(x, 0), expected.buffer.get(x, 0));
        }
    }

    #[test]
    fn render_shorter_label_clears_stale_suffix() {
        let long = Badge::new("LONG");
        let short = Badge::new("OK");

        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(8, 1, &mut pool);
        long.render(Rect::new(0, 0, 8, 1), &mut frame);
        short.render(Rect::new(0, 0, 8, 1), &mut frame);

        let row: String = (0..8)
            .map(|x| {
                frame
                    .buffer
                    .get(x, 0)
                    .and_then(|cell| cell.content.as_char())
                    .unwrap_or(' ')
            })
            .collect();
        assert_eq!(row, " OK     ");
    }

    #[test]
    fn badge_eq_and_hash() {
        let a = Badge::new("X").with_padding(1, 1);
        let b = Badge::new("X").with_padding(1, 1);
        assert_eq!(a, b);

        let mut set = std::collections::HashSet::new();
        set.insert(a);
        assert!(set.contains(&b));
    }

    #[test]
    fn badge_debug() {
        let badge = Badge::new("OK");
        let s = format!("{badge:?}");
        assert!(s.contains("Badge"));
        assert!(s.contains("OK"));
    }
}