liora-components 0.1.9

Enterprise-style native GPUI component library for Liora applications.
//! Text module.
//!
//! This public module implements the Liora selectable text component with typography options. It keeps the reusable
//! component logic inside `liora-components` rather than Gallery or Docs so
//! downstream GPUI applications can compose the same behavior with their own
//! app state, assets, and release policy.
//!
//! ## Usage model
//!
//! Components in this module render native GPUI element trees. Stateless builder
//! values can be constructed inline, while controls with focus, selection,
//! popup, drag, or editing state should be stored as `gpui::Entity<T>` fields in
//! the parent view so state survives GPUI render passes.
//!
//! ## Design contract
//!
//! The implementation should use Liora theme tokens from `liora-core` and
//! `liora-theme`, keep accessibility-oriented keyboard/pointer behavior close to
//! the component, and avoid app-specific Gallery/Docs resources in this SDK
//! crate.

use crate::{SelectableText, SelectableTextOptions, SelectableTextWrap};
use gpui::{
    App, Component, ElementId, FontStyle, FontWeight, Hsla, IntoElement, Pixels, RenderOnce,
    SharedString, StrikethroughStyle, TextRun, TextStyle, UnderlineStyle, Window, div, prelude::*,
    px,
};
use liora_core::{Config, code_font_family};

#[derive(Clone)]
/// Fluent native GPUI component for rendering Liora text.
pub struct Text {
    pub(crate) content: SharedString,
    pub(crate) color: Option<Hsla>,
    pub(crate) bg: Option<Hsla>,
    pub(crate) size: Option<Pixels>,
    pub(crate) weight: Option<FontWeight>,
    pub(crate) style: Option<FontStyle>,
    pub(crate) underline: bool,
    pub(crate) strikethrough: bool,
    pub(crate) font_family: Option<SharedString>,
    pub(crate) is_code_style: bool,
    pub(crate) wrap: bool,
    pub(crate) fill_width_on_wrap: bool,
    pub(crate) selectable: bool,
    pub(crate) id: SharedString,
}

impl Text {
    /// Creates `Text` initialized from the supplied content.
    pub fn new(content: impl Into<SharedString>) -> Self {
        Self {
            content: content.into(),
            color: None,
            bg: None,
            size: None,
            weight: None,
            style: None,
            underline: false,
            strikethrough: false,
            font_family: None,
            is_code_style: false,
            wrap: true,
            fill_width_on_wrap: false,
            selectable: true,
            id: liora_core::unique_id("text"),
        }
    }

    /// Applies the foreground text color.
    pub fn text_color(mut self, color: Hsla) -> Self {
        self.color = Some(color);
        self
    }

    /// Sets the bg used by the rendered component.
    pub fn bg(mut self, bg: Hsla) -> Self {
        self.bg = Some(bg);
        self
    }

    /// Sets an explicit icon size while preserving the default color behavior.
    pub fn size(mut self, size: impl Into<Pixels>) -> Self {
        self.size = Some(size.into());
        self
    }

    /// Sets the xs value used by the component.
    pub fn xs(self) -> Self {
        self.size(px(12.0))
    }

    /// Sets the sm value used by the component.
    pub fn sm(self) -> Self {
        self.size(px(14.0))
    }

    /// Sets the weight value used by the component.
    pub fn weight(mut self, weight: FontWeight) -> Self {
        self.weight = Some(weight);
        self
    }

    /// Applies bold font weight.
    pub fn bold(mut self) -> Self {
        self.weight = Some(FontWeight::BOLD);
        self
    }

    /// Sets the font style value used by the component.
    pub fn font_style(mut self, style: FontStyle) -> Self {
        self.style = Some(style);
        self
    }

    /// Sets the italic value used by the component.
    pub fn italic(mut self) -> Self {
        self.style = Some(FontStyle::Italic);
        self
    }

    /// Sets the underline value used by the component.
    pub fn underline(mut self) -> Self {
        self.underline = true;
        self
    }

    /// Sets the strikethrough value used by the component.
    pub fn strikethrough(mut self) -> Self {
        self.strikethrough = true;
        self
    }

    /// Sets the font family value used by the component.
    pub fn font_family(mut self, family: impl Into<SharedString>) -> Self {
        self.font_family = Some(family.into());
        self
    }

    /// Enable normal whitespace wrapping and let the text take the parent width.
    pub fn wrap(mut self) -> Self {
        self.wrap = true;
        self.fill_width_on_wrap = true;
        self
    }

    /// Alias for [`Text::wrap`].
    pub fn auto_wrap(self) -> Self {
        self.wrap()
    }

    /// Keep the text on a single line.
    pub fn nowrap(mut self) -> Self {
        self.wrap = false;
        self.fill_width_on_wrap = false;
        self
    }

    /// Toggles whether the rendered text can be selected.
    pub fn selectable(mut self, selectable: bool) -> Self {
        self.selectable = selectable;
        self
    }

    /// Assigns a stable element id used by GPUI state, hit testing, and automated interaction tests.
    pub fn id(mut self, id: impl Into<SharedString>) -> Self {
        self.id = id.into();
        self
    }

    /// Convenience for inline code styling.
    ///
    /// The font family is resolved during render so the default remains GPUI's
    /// platform monospace family unless the application configured a custom
    /// Liora code font.
    pub fn code_style(mut self, theme: &liora_theme::Theme) -> Self {
        self.bg = Some(theme.neutral.hover);
        self.is_code_style = true;
        self.text_color(theme.danger.base)
    }

    pub(crate) fn apply_to_text_style(&self, mut style: TextStyle) -> TextStyle {
        if let Some(color) = self.color {
            style.color = color;
        }

        if let Some(bg) = self.bg {
            style.background_color = Some(bg);
        }

        if let Some(weight) = self.weight {
            style.font_weight = weight;
        }

        if let Some(font_style) = self.style {
            style.font_style = font_style;
        }

        if let Some(family) = self.font_family.clone() {
            style.font_family = family;
        }

        if self.underline {
            style.underline = Some(UnderlineStyle {
                thickness: px(1.0),
                color: self.color,
                ..Default::default()
            });
        }

        if self.strikethrough {
            style.strikethrough = Some(StrikethroughStyle {
                thickness: px(1.0),
                color: self.color,
            });
        }

        style
    }

    pub(crate) fn to_text_run(&self, default_style: &TextStyle) -> TextRun {
        self.apply_to_text_style(default_style.clone())
            .to_run(self.content.len())
    }

    /// Registers GPUI key bindings required for keyboard interaction.
    pub fn register_key_bindings(cx: &mut App) {
        SelectableText::register_key_bindings(cx);
    }
}

impl RenderOnce for Text {
    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
        let theme = &cx.global::<Config>().theme;

        let font_size = self.size.unwrap_or_else(|| px(theme.font_size.md));
        let line_height = font_size * 1.6;
        let text_color = self.color.unwrap_or(theme.neutral.text_2);

        let mut text = self;
        if text.is_code_style && text.font_family.is_none() {
            text.font_family = Some(code_font_family(cx));
        }

        if text.selectable {
            let mut base_style = TextStyle::default();
            base_style.color = text_color;
            base_style.font_size = font_size.into();
            base_style.line_height = line_height.into();
            base_style.white_space = if text.wrap {
                gpui::WhiteSpace::Normal
            } else {
                gpui::WhiteSpace::Nowrap
            };
            let run = text.to_text_run(&base_style);
            return SelectableText::view(
                SelectableTextOptions {
                    id: ElementId::from(text.id.clone()),
                    text: text.content.clone(),
                    runs: vec![run],
                    font_size,
                    line_height,
                    text_color,
                    wrap: if text.wrap {
                        SelectableTextWrap::Normal
                    } else {
                        SelectableTextWrap::NoWrap
                    },
                    key_context: "SelectableText",
                    fill_width: text.fill_width_on_wrap,
                },
                _window,
                cx,
            );
        }

        let mut el = div()
            .child(text.content.clone())
            .text_size(font_size)
            .line_height(line_height)
            .text_color(text_color);

        if text.wrap {
            el = el.whitespace_normal();
            if text.fill_width_on_wrap {
                el = el.w_full().flex_shrink(1.0);
            }
        } else {
            el = el.whitespace_nowrap();
        }

        if let Some(bg) = text.bg {
            el = el.bg(bg).px_1().rounded(px(2.0));
        }

        if let Some(weight) = text.weight {
            el = el.font_weight(weight);
        }

        if let Some(style) = text.style {
            // In some GPUI versions, it's .italic(), in others it's .font_style(style)
            // If .font_style failed, let's try matching on style
            if style == FontStyle::Italic {
                el = el.italic();
            }
        }

        if text.underline {
            el = el.underline();
        }

        if text.strikethrough {
            el = el.line_through();
        }

        if let Some(family) = text.font_family {
            el = el.font_family(family);
        }

        el.into_any_element()
    }
}

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