kael_ui 0.2.0

Professional shadcn-inspired UI component library for Kael. 100+ accessible components for building beautiful, performant desktop applications.
use kael::*;

use crate::theme::use_theme;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CodeBlockCopyState {
    #[default]
    Idle,
    Copied,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TokenKind {
    Keyword,
    StringLiteral,
    Comment,
    Number,
    Plain,
}

#[derive(IntoElement)]
pub struct CodeBlock {
    base: Div,
    code: SharedString,
    language: Option<SharedString>,
    show_line_numbers: bool,
    show_copy_button: bool,
    highlight_lines: Vec<usize>,
    max_height: Option<Pixels>,
}

impl CodeBlock {
    pub fn new(code: impl Into<SharedString>) -> Self {
        Self {
            base: div(),
            code: code.into(),
            language: None,
            show_line_numbers: true,
            show_copy_button: true,
            highlight_lines: Vec::new(),
            max_height: None,
        }
    }

    pub fn language(mut self, lang: impl Into<SharedString>) -> Self {
        self.language = Some(lang.into());
        self
    }

    pub fn show_line_numbers(mut self, show: bool) -> Self {
        self.show_line_numbers = show;
        self
    }

    pub fn highlight_lines(mut self, lines: Vec<usize>) -> Self {
        self.highlight_lines = lines;
        self
    }

    pub fn max_height(mut self, height: Pixels) -> Self {
        self.max_height = Some(height);
        self
    }

    pub fn show_copy_button(mut self, show: bool) -> Self {
        self.show_copy_button = show;
        self
    }
}

impl RenderOnce for CodeBlock {
    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
        let theme = use_theme();
        let lines: Vec<&str> = self.code.split('\n').collect();
        let is_rust = self
            .language
            .as_ref()
            .map(|l| l.as_ref() == "rust" || l.as_ref() == "rs")
            .unwrap_or(false);

        let keyword_color = theme.tokens.primary;
        let string_color = hsla(0.4, 0.7, 0.5, 1.0);
        let comment_color = theme.tokens.muted_foreground;
        let number_color = hsla(0.08, 0.7, 0.6, 1.0);
        let plain_color = theme.tokens.foreground;
        let line_number_color = theme.tokens.muted_foreground;
        let highlight_bg = theme.tokens.muted.opacity(0.5);

        let gutter_width = px(40.0);

        let code_for_copy = self.code.clone();
        let show_copy = self.show_copy_button;

        let mut outer = self
            .base
            .relative()
            .bg(theme.tokens.muted.opacity(0.3))
            .rounded(theme.tokens.radius_md)
            .font_family(theme.tokens.font_mono.clone())
            .text_size(px(13.0))
            .overflow_hidden();

        let max_h = self.max_height;

        if show_copy {
            let copy_btn = div()
                .id("code-block-copy")
                .absolute()
                .top(px(8.0))
                .right(px(8.0))
                .px(px(8.0))
                .py(px(4.0))
                .rounded(theme.tokens.radius_sm)
                .bg(theme.tokens.muted.opacity(0.6))
                .text_color(theme.tokens.muted_foreground)
                .text_size(px(11.0))
                .cursor_pointer()
                .hover(|s| s.bg(theme.tokens.muted))
                .active(|s| s.opacity(0.7))
                .child("Copy")
                .on_click(move |_, _window, cx| {
                    cx.write_to_clipboard(ClipboardItem::new_string(code_for_copy.to_string()));
                });
            outer = outer.child(copy_btn);
        }

        let mut content = div().flex().flex_col().py(px(12.0));

        for (idx, line_text) in lines.iter().enumerate() {
            let line_num = idx + 1;
            let is_highlighted = self.highlight_lines.contains(&line_num);

            let mut row = div().flex().flex_row().px(px(12.0));

            if is_highlighted {
                row = row.bg(highlight_bg);
            }

            if self.show_line_numbers {
                row = row.child(
                    div()
                        .w(gutter_width)
                        .flex_shrink_0()
                        .text_color(line_number_color)
                        .text_size(px(12.0))
                        .text_right()
                        .pr(px(12.0))
                        .child(format!("{}", line_num)),
                );
            }

            let mut code_row = div().flex().flex_row().flex_1().min_w_0();
            let tokens = tokenize(line_text, is_rust);

            for (kind, text) in tokens {
                let color = match kind {
                    TokenKind::Keyword => keyword_color,
                    TokenKind::StringLiteral => string_color,
                    TokenKind::Comment => comment_color,
                    TokenKind::Number => number_color,
                    TokenKind::Plain => plain_color,
                };
                code_row = code_row.child(div().text_color(color).child(text.to_string()));
            }

            row = row.child(code_row);
            content = content.child(row);
        }

        if let Some(h) = max_h {
            outer.child(
                div()
                    .id("code-block-scroll")
                    .max_h(h)
                    .overflow_y_scroll()
                    .child(content),
            )
        } else {
            outer.child(content)
        }
    }
}

fn tokenize(line: &str, is_rust: bool) -> Vec<(TokenKind, &str)> {
    let mut tokens = Vec::new();
    let bytes = line.as_bytes();
    let len = bytes.len();
    let mut pos = 0;

    while pos < len {
        if pos + 1 < len && bytes[pos] == b'/' && bytes[pos + 1] == b'/' {
            tokens.push((TokenKind::Comment, &line[pos..]));
            return tokens;
        }

        if bytes[pos] == b'"' {
            let start = pos;
            pos += 1;
            while pos < len && bytes[pos] != b'"' {
                if bytes[pos] == b'\\' && pos + 1 < len {
                    pos += 1;
                }
                pos += 1;
            }
            if pos < len {
                pos += 1;
            }
            tokens.push((TokenKind::StringLiteral, &line[start..pos]));
            continue;
        }

        if bytes[pos] == b'\'' && is_rust {
            let start = pos;
            pos += 1;
            if pos < len && bytes[pos] == b'\\' && pos + 1 < len {
                pos += 2;
            } else if pos < len {
                pos += 1;
            }
            if pos < len && bytes[pos] == b'\'' {
                pos += 1;
                tokens.push((TokenKind::StringLiteral, &line[start..pos]));
                continue;
            }
            pos = start + 1;
            tokens.push((TokenKind::Plain, &line[start..start + 1]));
            continue;
        }

        if bytes[pos].is_ascii_digit()
            || (bytes[pos] == b'-' && pos + 1 < len && bytes[pos + 1].is_ascii_digit())
        {
            let start = pos;
            if bytes[pos] == b'-' {
                pos += 1;
            }
            while pos < len
                && (bytes[pos].is_ascii_digit() || bytes[pos] == b'.' || bytes[pos] == b'_')
            {
                pos += 1;
            }
            if pos < len && (bytes[pos] == b'e' || bytes[pos] == b'E') {
                pos += 1;
                if pos < len && (bytes[pos] == b'+' || bytes[pos] == b'-') {
                    pos += 1;
                }
                while pos < len && bytes[pos].is_ascii_digit() {
                    pos += 1;
                }
            }
            tokens.push((TokenKind::Number, &line[start..pos]));
            continue;
        }

        if bytes[pos].is_ascii_alphabetic() || bytes[pos] == b'_' {
            let start = pos;
            while pos < len && (bytes[pos].is_ascii_alphanumeric() || bytes[pos] == b'_') {
                pos += 1;
            }
            let word = &line[start..pos];
            if is_rust && is_rust_keyword(word) {
                tokens.push((TokenKind::Keyword, word));
            } else {
                tokens.push((TokenKind::Plain, word));
            }
            continue;
        }

        if bytes[pos] == b' ' {
            let start = pos;
            while pos < len && bytes[pos] == b' ' {
                pos += 1;
            }
            tokens.push((TokenKind::Plain, &line[start..pos]));
            continue;
        }

        let start = pos;
        pos += 1;
        tokens.push((TokenKind::Plain, &line[start..pos]));
    }

    tokens
}

fn is_rust_keyword(word: &str) -> bool {
    matches!(
        word,
        "fn" | "let"
            | "mut"
            | "pub"
            | "struct"
            | "enum"
            | "impl"
            | "use"
            | "mod"
            | "if"
            | "else"
            | "for"
            | "while"
            | "match"
            | "return"
            | "self"
            | "Self"
            | "crate"
            | "super"
            | "true"
            | "false"
            | "async"
            | "await"
            | "move"
            | "ref"
            | "where"
            | "type"
            | "trait"
            | "const"
            | "static"
            | "loop"
            | "break"
            | "continue"
            | "in"
            | "as"
            | "unsafe"
            | "dyn"
            | "extern"
    )
}

impl Styled for CodeBlock {
    fn style(&mut self) -> &mut StyleRefinement {
        self.base.style()
    }
}

impl InteractiveElement for CodeBlock {
    fn interactivity(&mut self) -> &mut Interactivity {
        self.base.interactivity()
    }
}

impl StatefulInteractiveElement for CodeBlock {}

impl ParentElement for CodeBlock {
    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
        self.base.extend(elements)
    }
}