liora-components 0.1.0

Enterprise-style native GPUI component library for Liora applications.
Documentation
use crate::{SelectableText, SelectableTextOptions, SelectableTextWrap, Text};
use gpui::{
    App, Component, ElementId, IntoElement, RenderOnce, SharedString, StyledText, TextRun,
    TextStyle, WhiteSpace, Window, div, prelude::*, px,
};
use liora_core::Config;

pub struct Paragraph {
    children: Vec<Text>,
    selectable: bool,
    id: SharedString,
}

impl Paragraph {
    pub fn new() -> Self {
        Self {
            children: Vec::new(),
            selectable: true,
            id: liora_core::unique_id("paragraph"),
        }
    }

    pub fn with_text(text: impl Into<SharedString>) -> Self {
        Self {
            children: vec![Text::new(text)],
            selectable: true,
            id: liora_core::unique_id("paragraph"),
        }
    }

    pub fn child(mut self, child: Text) -> Self {
        self.children.push(child);
        self
    }

    pub fn children(mut self, children: impl IntoIterator<Item = Text>) -> Self {
        self.children.extend(children);
        self
    }

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

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

    pub fn register_key_bindings(cx: &mut App) {
        SelectableText::register_key_bindings(cx);
    }

    fn default_text_style(theme: &liora_theme::Theme) -> TextStyle {
        let font_size = px(theme.font_size.md);
        let mut style = TextStyle::default();
        style.color = theme.neutral.text_2;
        style.font_size = font_size.into();
        style.line_height = px(theme.font_size.md * 1.6).into();
        style.white_space = WhiteSpace::Normal;
        style.text_overflow = None;
        style.line_clamp = None;
        style
    }

    fn styled_text_parts(&self, theme: &liora_theme::Theme) -> (SharedString, Vec<TextRun>) {
        let default_style = Self::default_text_style(theme);
        let mut full_text = String::new();
        let mut runs: Vec<TextRun> = Vec::new();

        for segment in &self.children {
            if segment.content.is_empty() {
                continue;
            }

            let segment_text = segment.content.clone();
            let text = segment_text.as_ref();
            let leading_glue_len = if runs.is_empty() {
                0
            } else {
                leading_no_line_start_len(text)
            };

            if leading_glue_len > 0 {
                full_text.push_str(&text[..leading_glue_len]);
                if let Some(previous_run) = runs.last_mut() {
                    previous_run.len += leading_glue_len;
                }
            }

            let remaining = &text[leading_glue_len..];
            if !remaining.is_empty() {
                full_text.push_str(remaining);
                let mut run = segment.to_text_run(&default_style);
                run.len = remaining.len();
                runs.push(run);
            }
        }

        (full_text.into(), runs)
    }
}

fn leading_no_line_start_len(text: &str) -> usize {
    let Some(first) = text.chars().next() else {
        return 0;
    };

    if !is_no_line_start_punctuation(first) {
        return 0;
    }

    let mut end = 0;
    let mut saw_punctuation = false;
    for (index, ch) in text.char_indices() {
        if is_no_line_start_punctuation(ch) {
            saw_punctuation = true;
            end = index + ch.len_utf8();
            continue;
        }

        if saw_punctuation && ch.is_whitespace() {
            end = index + ch.len_utf8();
            continue;
        }

        break;
    }

    end
}

fn is_no_line_start_punctuation(ch: char) -> bool {
    matches!(
        ch,
        ':' | ''
            | ','
            | ''
            | '.'
            | ''
            | ';'
            | ''
            | '!'
            | ''
            | '?'
            | ''
            | ''
            | ')'
            | ''
            | ']'
            | ''
            | '}'
            | ''
            | ''
            | ''
            | ''
            | ''
    )
}

impl RenderOnce for Paragraph {
    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
        let theme = &cx.global::<Config>().theme;
        let (full_text, runs) = self.styled_text_parts(theme);
        let font_size = px(theme.font_size.md);

        if self.selectable {
            return SelectableText::view(
                SelectableTextOptions {
                    id: ElementId::from(self.id.clone()),
                    text: full_text,
                    runs,
                    font_size,
                    line_height: font_size * 1.6,
                    text_color: theme.neutral.text_2,
                    wrap: SelectableTextWrap::Normal,
                    key_context: "SelectableText",
                    fill_width: true,
                },
                _window,
                cx,
            );
        }

        div()
            .w_full()
            .text_size(font_size)
            .line_height(font_size * 1.6)
            .text_color(theme.neutral.text_2)
            .whitespace_normal()
            .child(StyledText::new(full_text).with_runs(runs))
            .into_any_element()
    }
}

impl IntoElement for Paragraph {
    type Element = Component<Self>;
    fn into_element(self) -> Self::Element {
        Component::new(self)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use gpui::{FontStyle, FontWeight};

    #[test]
    fn text_and_paragraph_use_selectable_text_for_native_selection() {
        let text_source = include_str!("text.rs");
        let paragraph_source = include_str!("paragraph.rs");
        let selectable_source = include_str!("selectable_text.rs");

        assert!(text_source.contains("SelectableText::view"));
        assert!(paragraph_source.contains("SelectableText::view"));
        assert!(text_source.contains("pub fn selectable"));
        assert!(paragraph_source.contains("pub fn selectable"));
        assert!(selectable_source.contains("event.click_count == 2"));
        assert!(selectable_source.contains("ClipboardItem::new_string"));
        assert!(selectable_source.contains(r#"KeyBinding::new("ctrl-c""#));
        assert!(selectable_source.contains("window.capture_pointer"));
        assert!(selectable_source.contains("cx.on_blur(&self.focus_handle"));
        assert!(selectable_source.contains("fn clear_selection"));
    }

    #[test]
    fn paragraph_composes_segments_into_one_styled_text_run_list() {
        let theme = liora_theme::Theme::light();
        let (text, runs) = Paragraph::new()
            .child(Text::new("Hello ").bold())
            .child(Text::new("世界").italic())
            .styled_text_parts(&theme);

        assert_eq!(text.as_ref(), "Hello 世界");
        assert_eq!(runs.len(), 2);
        assert_eq!(runs[0].len, "Hello ".len());
        assert_eq!(runs[1].len, "世界".len());
        assert_eq!(runs[0].font.weight, FontWeight::BOLD);
        assert_eq!(runs[1].font.style, FontStyle::Italic);
    }

    #[test]
    fn paragraph_glues_line_start_forbidden_punctuation_to_previous_run() {
        let theme = liora_theme::Theme::light();
        let (text, runs) = Paragraph::new()
            .child(Text::new("crates/liora-components").code_style(&theme))
            .child(Text::new(":所有可复用组件,例如 "))
            .child(Text::new("Button").code_style(&theme))
            .child(Text::new(""))
            .child(Text::new("Input").code_style(&theme))
            .child(Text::new(""))
            .styled_text_parts(&theme);

        assert_eq!(
            text.as_ref(),
            "crates/liora-components:所有可复用组件,例如 Button、Input。"
        );
        assert_eq!(runs[0].len, "crates/liora-components:".len());
        assert_eq!(runs[2].len, "Button、".len());
        assert_eq!(runs[3].len, "Input。".len());
    }

    #[test]
    fn text_segments_map_inline_code_style_to_text_runs() {
        let theme = liora_theme::Theme::light();
        let default_style = Paragraph::default_text_style(&theme);
        let run = Text::new("code")
            .code_style(&theme)
            .bold()
            .underline()
            .to_text_run(&default_style);

        assert_eq!(run.len, "code".len());
        assert_eq!(run.font.family.as_ref(), "Monospace");
        assert_eq!(run.font.weight, FontWeight::BOLD);
        assert_eq!(run.color, theme.danger.base);
        assert_eq!(run.background_color, Some(theme.neutral.hover));
        assert!(run.underline.is_some());
    }

    #[test]
    fn paragraph_default_style_keeps_native_wrapping_without_truncation() {
        let theme = liora_theme::Theme::light();
        let style = Paragraph::default_text_style(&theme);

        assert_eq!(style.white_space, WhiteSpace::Normal);
        assert!(style.text_overflow.is_none());
        assert!(style.line_clamp.is_none());
    }
}