turbo-vision 2.2.0

A Rust implementation of the classic Borland Turbo Vision text-mode UI framework
Documentation
// (C) 2025 - Enzo Lombardi

//! Drawing primitives - Cell and DrawBuffer types for efficient line-based rendering.

use super::palette::Attr;

/// A single character cell with attributes
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Cell {
    pub ch: char,
    pub attr: Attr,
}

impl Cell {
    pub const fn new(ch: char, attr: Attr) -> Self {
        Self { ch, attr }
    }
}

/// Buffer for efficient line-based drawing
pub struct DrawBuffer {
    pub data: Vec<Cell>,
}

impl DrawBuffer {
    /// Create a new draw buffer with the given width
    pub fn new(width: usize) -> Self {
        Self {
            data: vec![Cell::new(' ', Attr::from_u8(0x07)); width],
        }
    }

    /// Fill a range with a single character and attribute
    pub fn move_char(&mut self, pos: usize, ch: char, attr: Attr, count: usize) {
        let end = (pos + count).min(self.data.len());
        for i in pos..end {
            self.data[i] = Cell::new(ch, attr);
        }
    }

    /// Write a string with the given attribute
    pub fn move_str(&mut self, pos: usize, s: &str, attr: Attr) {
        use unicode_width::UnicodeWidthChar;
        let mut i = pos;
        for ch in s.chars() {
            if i >= self.data.len() {
                break;
            }
            let w = ch.width().unwrap_or(0);
            // Skip zero-width characters (combining marks, variation
            // selectors like U+FE0F).  They must not occupy their own
            // cell — otherwise the buffer width diverges from what the
            // terminal actually renders, misaligning subsequent content.
            if w == 0 {
                continue;
            }
            self.data[i] = Cell::new(ch, attr);
            i += 1;
            // Wide characters (emojis, CJK) occupy 2 cells — fill the
            // second cell with a zero-width padding space so the cursor
            // advances correctly.
            if w > 1 {
                for _ in 1..w {
                    if i >= self.data.len() {
                        break;
                    }
                    self.data[i] = Cell::new('\0', attr);
                    i += 1;
                }
            }
        }
    }

    /// Copy cells from another buffer
    pub fn move_buf(&mut self, pos: usize, src: &[Cell], count: usize) {
        let end = (pos + count).min(self.data.len()).min(pos + src.len());
        self.data[pos..end].copy_from_slice(&src[..(end - pos)]);
    }

    /// Put a single character at a position
    pub fn put_char(&mut self, pos: usize, ch: char, attr: Attr) {
        if pos < self.data.len() {
            self.data[pos] = Cell::new(ch, attr);
        }
    }

    /// Change the attribute of a cell without changing its character
    /// Matches Borland: TDrawBuffer::putAttribute (help.cc:109)
    pub fn put_attribute(&mut self, pos: usize, attr: Attr) {
        if pos < self.data.len() {
            self.data[pos].attr = attr;
        }
    }

    /// Get the length of the buffer
    pub fn len(&self) -> usize {
        self.data.len()
    }

    /// Check if the buffer is empty
    pub fn is_empty(&self) -> bool {
        self.data.is_empty()
    }

    /// Write a string with shortcut highlighting
    /// Format: "~X~" highlights X with shortcut_attr, rest uses normal_attr
    /// Example: "~F~ile" displays "File" with "F" highlighted
    pub fn move_str_with_shortcut(
        &mut self,
        mut pos: usize,
        s: &str,
        normal_attr: Attr,
        shortcut_attr: Attr,
    ) -> usize {
        use unicode_width::UnicodeWidthChar;
        let mut chars = s.chars();
        let start_pos = pos;

        // Helper: write a char at `pos`, advancing by its display width
        let put_wide = |data: &mut [Cell], pos: &mut usize, ch: char, attr: Attr| {
            if *pos >= data.len() {
                return;
            }
            let w = ch.width().unwrap_or(0);
            if w == 0 {
                return;
            }
            data[*pos] = Cell::new(ch, attr);
            *pos += 1;
            for _ in 1..w {
                if *pos >= data.len() {
                    break;
                }
                data[*pos] = Cell::new('\0', attr);
                *pos += 1;
            }
        };

        // Parse string character by character, handling ~X~ shortcut highlighting
        while let Some(ch) = chars.next() {
            if pos >= self.data.len() {
                break;
            }

            if ch == '~' {
                // Read characters until closing ~ and render with shortcut color
                while let Some(shortcut_ch) = chars.next() {
                    if shortcut_ch == '~' {
                        break; // Found closing tilde
                    }
                    put_wide(&mut self.data, &mut pos, shortcut_ch, shortcut_attr);
                }
            } else if ch.is_control() {
                // Skip control characters (e.g. \x03 centering directive)
                // These are directives, not renderable glyphs
                continue;
            } else {
                put_wide(&mut self.data, &mut pos, ch, normal_attr);
            }
        }

        pos - start_pos // Return number of cells written
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::palette::TvColor;

    #[test]
    fn test_draw_buffer_basic() {
        let buf = DrawBuffer::new(10);
        assert_eq!(buf.len(), 10);
        assert!(!buf.is_empty());
    }

    #[test]
    fn test_move_char() {
        let mut buf = DrawBuffer::new(10);
        let attr = Attr::new(TvColor::White, TvColor::Black);
        buf.move_char(2, 'X', attr, 3);
        assert_eq!(buf.data[1].ch, ' ');
        assert_eq!(buf.data[2].ch, 'X');
        assert_eq!(buf.data[3].ch, 'X');
        assert_eq!(buf.data[4].ch, 'X');
        assert_eq!(buf.data[5].ch, ' ');
    }

    #[test]
    fn test_move_str() {
        let mut buf = DrawBuffer::new(20);
        let attr = Attr::new(TvColor::White, TvColor::Black);
        buf.move_str(0, "Hello, World!", attr);
        assert_eq!(buf.data[0].ch, 'H');
        assert_eq!(buf.data[6].ch, ' ');
        assert_eq!(buf.data[12].ch, '!');
    }
}