panasyn 0.1.0

A lightweight GPU-accelerated terminal emulator for macOS and Linux.
use unicode_width::UnicodeWidthChar;

/// Packed color: type in bits 24-31, value in bits 0-23.
///   type=0 → default
///   type=1 → ANSI 16-color (value=index 0-15)
///   type=2 → 256-color (value=index 0-255)
///   type=3 → truecolor (value=0xRRGGBB)
pub type CellColor = u32;

pub fn color_default() -> CellColor {
    0
}
pub fn color_ansi(i: u8) -> CellColor {
    (1 << 24) | (i as u32 & 0xF)
}
pub fn color_indexed(i: u8) -> CellColor {
    (2 << 24) | i as u32
}
pub fn color_rgb(r: u8, g: u8, b: u8) -> CellColor {
    (3 << 24) | ((r as u32) << 16) | ((g as u32) << 8) | b as u32
}

pub fn color_type(c: CellColor) -> u8 {
    (c >> 24) as u8
}
pub fn color_value(c: CellColor) -> u32 {
    c & 0x00FF_FFFF
}

pub(crate) const DEFAULT_FG: CellColor = 0;
pub(crate) const DEFAULT_BG: CellColor = 0;

pub type CellAttrs = u16;
pub const ATTR_BOLD: CellAttrs = 1 << 0;
pub const ATTR_DIM: CellAttrs = 1 << 1;
pub const ATTR_ITALIC: CellAttrs = 1 << 2;
pub const ATTR_UNDERLINE: CellAttrs = 1 << 3;
pub const ATTR_BLINK: CellAttrs = 1 << 4;
pub const ATTR_INVERSE: CellAttrs = 1 << 5;
pub const ATTR_HIDDEN: CellAttrs = 1 << 6;
pub const ATTR_STRIKE: CellAttrs = 1 << 7;

/// A single terminal cell.
pub struct Cell {
    pub c: char,
    pub extra: Option<Box<str>>,
    pub fg: CellColor,
    pub bg: CellColor,
    pub attrs: CellAttrs,
    pub width: u8,
    pub continuation: bool,
    pub hyperlink_id: u32,
}

impl Clone for Cell {
    fn clone(&self) -> Self {
        Self {
            c: self.c,
            extra: self.extra.clone(),
            fg: self.fg,
            bg: self.bg,
            attrs: self.attrs,
            width: self.width,
            continuation: self.continuation,
            hyperlink_id: self.hyperlink_id,
        }
    }
}
impl std::fmt::Debug for Cell {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Cell")
            .field("c", &self.c)
            .field("extra", &self.extra)
            .field("fg", &self.fg)
            .field("bg", &self.bg)
            .field("attrs", &self.attrs)
            .field("width", &self.width)
            .field("continuation", &self.continuation)
            .field("hyperlink_id", &self.hyperlink_id)
            .finish()
    }
}
impl PartialEq for Cell {
    fn eq(&self, other: &Self) -> bool {
        self.c == other.c
            && self.extra == other.extra
            && self.fg == other.fg
            && self.bg == other.bg
            && self.attrs == other.attrs
            && self.width == other.width
            && self.continuation == other.continuation
            && self.hyperlink_id == other.hyperlink_id
    }
}

impl Cell {
    pub fn new(c: char) -> Self {
        Self::with_attrs(c, DEFAULT_FG, DEFAULT_BG, 0)
    }

    pub fn with_attrs(c: char, fg: CellColor, bg: CellColor, attrs: CellAttrs) -> Self {
        let width = char_display_width(c);
        Self {
            c,
            extra: None,
            fg: DEFAULT_FG,
            bg,
            attrs,
            width,
            continuation: false,
            hyperlink_id: 0,
        }
        .with_fg(fg)
    }

    pub fn ascii_with_attrs(byte: u8, fg: CellColor, bg: CellColor, attrs: CellAttrs) -> Self {
        debug_assert!(byte.is_ascii() && !byte.is_ascii_control());
        Self {
            c: byte as char,
            extra: None,
            fg,
            bg,
            attrs,
            width: 1,
            continuation: false,
            hyperlink_id: 0,
        }
    }

    fn with_fg(mut self, fg: CellColor) -> Self {
        self.fg = fg;
        self
    }

    pub fn blank() -> Self {
        Self {
            c: ' ',
            extra: None,
            fg: DEFAULT_FG,
            bg: DEFAULT_BG,
            attrs: 0,
            width: 1,
            continuation: false,
            hyperlink_id: 0,
        }
    }

    pub fn blank_with_attrs(fg: CellColor, bg: CellColor, attrs: CellAttrs) -> Self {
        Self {
            c: ' ',
            extra: None,
            fg,
            bg,
            attrs,
            width: 1,
            continuation: false,
            hyperlink_id: 0,
        }
    }

    pub fn continuation_of(cell: &Cell) -> Self {
        Self {
            c: ' ',
            extra: None,
            fg: cell.fg,
            bg: cell.bg,
            attrs: cell.attrs,
            width: 0,
            continuation: true,
            hyperlink_id: cell.hyperlink_id,
        }
    }

    pub fn with_hyperlink_id(mut self, hyperlink_id: u32) -> Self {
        self.hyperlink_id = hyperlink_id;
        self
    }

    pub fn append_combining(&mut self, c: char) {
        self.append_to_cluster(c);
    }

    pub fn append_to_cluster(&mut self, c: char) {
        let mut text = self.extra.take().map(String::from).unwrap_or_default();
        text.push(c);
        self.extra = Some(text.into_boxed_str());
        let width = if is_emoji_modifier(c) {
            self.width.saturating_add(char_display_width(c))
        } else {
            self.width.max(char_display_width(c))
        };
        self.width = width.clamp(1, 8);
    }

    pub fn text(&self) -> String {
        let mut text = String::new();
        self.push_text(&mut text);
        text
    }

    pub fn text_ends_with_zwj(&self) -> bool {
        self.c == ZERO_WIDTH_JOINER
            || self
                .extra
                .as_deref()
                .is_some_and(|text| text.ends_with(ZERO_WIDTH_JOINER))
    }

    pub fn is_single_regional_indicator(&self) -> bool {
        is_regional_indicator(self.c) && self.extra.is_none()
    }

    pub fn display_width(&self) -> usize {
        if self.continuation {
            0
        } else {
            self.width as usize
        }
    }

    pub fn push_text(&self, out: &mut String) {
        if self.continuation {
            return;
        }
        out.push(self.c);
        if let Some(extra) = &self.extra {
            out.push_str(extra);
        }
    }
}

pub fn char_display_width(c: char) -> u8 {
    if is_emoji_modifier(c) {
        return 2;
    }
    if is_zero_width_cluster_part(c) {
        return 0;
    }
    if c.is_ascii() {
        return 1;
    }
    UnicodeWidthChar::width(c).unwrap_or(0).min(2) as u8
}

pub const ZERO_WIDTH_JOINER: char = '\u{200d}';

pub fn is_regional_indicator(c: char) -> bool {
    matches!(c as u32, 0x1F1E6..=0x1F1FF)
}

pub fn is_emoji_modifier(c: char) -> bool {
    matches!(c as u32, 0x1F3FB..=0x1F3FF)
}

pub fn is_variation_selector(c: char) -> bool {
    matches!(c as u32, 0xFE00..=0xFE0F | 0xE0100..=0xE01EF)
}

pub fn is_tag_char(c: char) -> bool {
    matches!(c as u32, 0xE0020..=0xE007F)
}

pub fn is_zero_width_cluster_part(c: char) -> bool {
    c == ZERO_WIDTH_JOINER || c == '\u{20e3}' || is_variation_selector(c) || is_tag_char(c)
}

/// Map an ANSI color index (0-15) to an sRGB hex value.
fn ansi_color(index: u8) -> u32 {
    const PALETTE: [u32; 16] = [
        0x000000, 0xCC0000, 0x00CC00, 0xCCCC00, 0x0000CC, 0xCC00CC, 0x00CCCC, 0xCCCCCC, 0x555555,
        0xFF5555, 0x55FF55, 0xFFFF55, 0x5555FF, 0xFF55FF, 0x55FFFF, 0xFFFFFF,
    ];
    PALETTE[(index as usize).min(15)]
}

/// Colors from the 216-color cube (6×6×6) starting at index 16.
fn cube_color(index: u8) -> u32 {
    let i = (index - 16) as u32;
    let r = (i / 36) % 6;
    let g = (i / 6) % 6;
    let b = i % 6;
    let to_byte = |v: u32| -> u8 { (v * 51) as u8 }; // 0→0, 5→255
    (to_byte(r) as u32) << 16 | (to_byte(g) as u32) << 8 | to_byte(b) as u32
}

/// Grayscale colors (232-255).
fn gray_color(index: u8) -> u32 {
    let level = ((index - 232) as u32) * 10 + 8;
    level | (level << 8) | (level << 16)
}

/// Resolve a cell color to an sRGB packed value (0xRRGGBB) for rendering.
pub fn resolve_color(c: CellColor) -> u32 {
    match color_type(c) {
        1 => ansi_color(color_value(c) as u8),
        2 => {
            let idx = color_value(c) as u8;
            match idx {
                0..=15 => ansi_color(idx),
                16..=231 => cube_color(idx),
                _ => gray_color(idx),
            }
        }
        3 => color_value(c), // already truecolor
        _ => 0xCCCCCC,       // default → light gray text
    }
}

pub fn resolve_bg(c: CellColor) -> u32 {
    if c == DEFAULT_BG { 0 } else { resolve_color(c) }
}

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

    #[test]
    fn test_cell_default() {
        let c = Cell::new('x');
        assert_eq!(c.c, 'x');
        assert_eq!(c.fg, DEFAULT_FG);
        assert_eq!(c.bg, DEFAULT_BG);
    }

    #[test]
    fn test_color_roundtrip() {
        let c = color_rgb(0x12, 0x34, 0x56);
        assert_eq!(color_type(c), 3);
        assert_eq!(color_value(c), 0x123456);
    }

    #[test]
    fn test_resolve_ansi() {
        let r = resolve_color(color_ansi(1)); // red
        assert_eq!(r, 0xCC0000);
    }

    #[test]
    fn test_resolve_truecolor() {
        let r = resolve_color(color_rgb(0xAB, 0xCD, 0xEF));
        assert_eq!(r, 0xABCDEF);
    }
}