sveltui-code 0.1.0-alpha.2

TextMate syntax-highlighted code preview and editor components for sveltui
Documentation
use crate::binding::CodeBinding;
use crate::highlight::highlight_text;
use crossterm::event::{KeyCode, KeyEvent};
use crossterm::style::Color;
use std::rc::Rc;
use sveltui::element::scroll::{get_scroll, set_scroll};
use sveltui::layout::Dimension;
use sveltui::prelude::*;

#[derive(Props)]
pub struct CodePreviewProps {
    #[prop(optional)]
    pub id: Option<FocusId>,
    #[prop(optional)]
    pub value: Option<CodeBinding>,
    #[prop(optional)]
    pub code: Option<String>,
    #[prop(optional)]
    pub language: Option<String>,
    #[prop(optional)]
    pub theme: Option<String>,
    #[prop(default = true)]
    pub show_line_numbers: bool,
    #[prop(default = Dimension::Auto)]
    pub width: Dimension,
    #[prop(default = Dimension::Auto)]
    pub height: Dimension,
    #[prop(default = BorderStyle::Single)]
    pub border: BorderStyle,
    #[prop(optional)]
    pub border_color: Option<Color>,
    #[prop(optional)]
    pub title: Option<String>,
    #[prop(default = 4)]
    pub tab_size: usize,
}

#[component]
pub fn CodePreview(props: CodePreviewProps) -> impl IntoNode {
    let id = props.id.unwrap_or_default();
    let focused = is_focused(id);

    let border_style = if focused {
        BorderStyle::Double
    } else {
        props.border
    };

    let border_color = if focused {
        Color::White
    } else {
        props.border_color.unwrap_or(Color::DarkGrey)
    };

    let raw_text = if let Some(ref c) = props.code {
        c.clone()
    } else if let Some(ref b) = props.value {
        b.get_text()
    } else {
        String::new()
    };

    let all_line_spans =
        highlight_text(&raw_text, props.language.as_deref(), props.theme.as_deref());

    let total_lines = all_line_spans.len().max(1);
    let digits = format!("{}", total_lines).len().max(2);
    let show_line_numbers = props.show_line_numbers;

    let rendered_line_nodes: Vec<VNode> = all_line_spans
        .into_iter()
        .enumerate()
        .map(|(offset, spans)| {
            let line_num = offset + 1;
            let mut row = ViewProps::builder()
                .flex_direction(FlexDirection::Row)
                .align_items(AlignItems::Center)
                .justify_content(JustifyContent::FlexStart);
            if show_line_numbers {
                row = row.child(TextSpan(
                    TextSpanProps::builder()
                        .foreground_color(Color::DarkGrey)
                        .content(format!("{:>width$} │ ", line_num, width = digits))
                        .build(),
                ));
            }
            if spans.is_empty() {
                row = row.child(TextSpan(
                    TextSpanProps::builder()
                        .foreground_color(Color::DarkGrey)
                        .content(" ")
                        .build(),
                ));
            } else {
                for span in spans {
                    row = row.child(TextSpan(
                        TextSpanProps::builder()
                            .foreground_color(span.fg)
                            .bold(span.bold)
                            .italic(span.italic)
                            .underline(span.underline)
                            .content(span.text)
                            .build(),
                    ));
                }
            }
            View(row.build()).into_node()
        })
        .collect();

    let title_str = props.title.clone().unwrap_or_else(|| {
        if let Some(ref lang) = props.language {
            format!("Preview ({lang})")
        } else {
            String::from("Code Preview")
        }
    });

    let on_key: KeyHandler = Rc::new(move |key: KeyEvent, _ctx: &mut EventContext| {
        let (sx, sy) = get_scroll(id);
        let page_step = 10;

        match key.code {
            KeyCode::Up => {
                set_scroll(id, (sx, sy.saturating_sub(1)));
                EventResult::Handled
            }
            KeyCode::Down => {
                set_scroll(id, (sx, sy.saturating_add(1)));
                EventResult::Handled
            }
            KeyCode::PageUp => {
                set_scroll(id, (sx, sy.saturating_sub(page_step)));
                EventResult::Handled
            }
            KeyCode::PageDown => {
                set_scroll(id, (sx, sy.saturating_add(page_step)));
                EventResult::Handled
            }
            KeyCode::Home => {
                set_scroll(id, (0, 0));
                EventResult::Handled
            }
            KeyCode::End => {
                set_scroll(id, (sx, u16::MAX));
                EventResult::Handled
            }
            KeyCode::Left => {
                set_scroll(id, (sx.saturating_sub(2), sy));
                EventResult::Handled
            }
            KeyCode::Right => {
                set_scroll(id, (sx.saturating_add(2), sy));
                EventResult::Handled
            }
            _ => EventResult::Ignored,
        }
    });

    let on_scroll: Rc<dyn Fn(i16, i16)> = Rc::new(move |_dx: i16, dy: i16| {
        let (sx, sy) = get_scroll(id);
        let ny = if dy > 0 {
            sy.saturating_add(dy as u16)
        } else {
            sy.saturating_sub((-dy) as u16)
        };
        set_scroll(id, (sx, ny));
    });

    view! {
        <Pressable
            focus_id=id
            tab_stop=true
            on_key=on_key
            semantics=Semantics::new(Role::Generic, title_str)
        >
            <View
                border=border_style
                border_color=border_color
                padding=Padding::symmetric(1, 0)
                width=props.width
                height=props.height
                flex_direction=FlexDirection::Column
                align_items=AlignItems::FlexStart
                justify_content=JustifyContent::FlexStart
                overflow_x=Overflow::Scroll
                overflow_y=Overflow::Scroll
                scroll_id=id
                on_scroll=on_scroll
                opt_title=props.title
            >
                {#each rendered_line_nodes as line_node}
                    {line_node}
                {/each}
            </View>
        </Pressable>
    }
}