Skip to main content

herogpui_components/
typography.rs

1//! Typography — port of `@heroui/typography` (HeroUI v3.2.4).
2//!
3//! Semantic typography primitives for headings, body copy and inline code.
4//! Mirrors the React API: `type`, `align`, `color`, `weight`, `truncate`,
5//! plus the `Typography.Heading` / `Typography.Paragraph` / `Typography.Code`
6//! / `Typography.Prose` convenience primitives.
7//!
8//! Paint-only omissions, each because GPUI 0.2.2 lacks the primitive: the
9//! headings' `tracking-tight` (no letter-spacing), `align: justify` (falls
10//! back to start alignment), and the semantic `h1`–`h6`/`p`/`code` elements
11//! (rendered as plain divs). The mono family is Tailwind's default mono stack
12//! resolved on Windows. Upstream's per-tag `Prose` descendant styles cannot
13//! be ported because GPUI has no ancestor context propagation.
14
15use gpui::{
16    div, px, AnyElement, App, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, Styled,
17    Window,
18};
19use herogpui_theme::ActiveTheme;
20
21use crate::util::MONO_FONT;
22
23/// Semantic typography style (`type` prop).
24///
25/// `(font-size, line-height)` pairs resolve the pinned `typography.css`
26/// through Tailwind 4.3.0's default text scale: headings use the default
27/// `text-*` leading (`h1` 36/40 through `h6` 16/24), body `leading-7`
28/// (16/28), body-sm `leading-6` (14/24), body-xs `leading-5` (12/20) and
29/// code the plain `text-sm` leading (14/20).
30#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31pub enum TypographyType {
32    /// Level-1 heading (36/40).
33    H1,
34    /// Level-2 heading (30/36).
35    H2,
36    /// Level-3 heading (24/32).
37    H3,
38    /// Level-4 heading (20/28).
39    H4,
40    /// Level-5 heading (18/28).
41    H5,
42    /// Level-6 heading (16/24).
43    H6,
44    /// Body text (16/28); the default.
45    #[default]
46    Body,
47    /// Small body text (14/24).
48    BodySm,
49    /// Extra-small body text (12/20).
50    BodyXs,
51    /// Monospace code text (14/20).
52    Code,
53}
54
55impl TypographyType {
56    /// `(font-size, line-height)` in pixels.
57    pub fn metrics(self) -> (Pixels, Pixels) {
58        match self {
59            Self::H1 => (px(36.), px(40.)),
60            Self::H2 => (px(30.), px(36.)),
61            Self::H3 => (px(24.), px(32.)),
62            Self::H4 => (px(20.), px(28.)),
63            Self::H5 => (px(18.), px(28.)),
64            Self::H6 => (px(16.), px(24.)),
65            Self::Body => (px(16.), px(28.)),
66            Self::BodySm => (px(14.), px(24.)),
67            Self::BodyXs => (px(12.), px(20.)),
68            Self::Code => (px(14.), px(20.)),
69        }
70    }
71
72    /// Default weight for this type — headings are semibold, body normal.
73    pub fn default_weight(self) -> FontWeight {
74        match self {
75            Self::H1 | Self::H2 | Self::H3 | Self::H4 | Self::H5 | Self::H6 => FontWeight::Semibold,
76            _ => FontWeight::Normal,
77        }
78    }
79
80    /// Whether this type renders in the monospace family.
81    pub fn is_mono(self) -> bool {
82        matches!(self, Self::Code)
83    }
84
85    /// Maps a heading level (1-6) to the matching type; higher levels clamp.
86    pub fn heading(level: u8) -> Self {
87        match level {
88            1 => Self::H1,
89            2 => Self::H2,
90            3 => Self::H3,
91            4 => Self::H4,
92            5 => Self::H5,
93            _ => Self::H6,
94        }
95    }
96}
97
98/// Text alignment (`align` prop).
99#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
100pub enum TextAlign {
101    /// Aligned to the start; renders left-aligned.
102    #[default]
103    Start,
104    /// Centred.
105    Center,
106    /// Aligned to the end; renders right-aligned.
107    End,
108    /// Justified; currently renders left-aligned.
109    Justify,
110}
111
112/// Text color (`color` prop).
113#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
114pub enum TextColor {
115    /// The default foreground colour.
116    #[default]
117    Default,
118    /// The muted foreground colour.
119    Muted,
120}
121
122/// Font weight override (`weight` prop).
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub enum FontWeight {
125    /// Normal weight.
126    Normal,
127    /// Medium weight.
128    Medium,
129    /// Semibold weight.
130    Semibold,
131    /// Bold weight.
132    Bold,
133}
134
135impl FontWeight {
136    fn to_gpui(self) -> gpui::FontWeight {
137        match self {
138            Self::Normal => gpui::FontWeight::NORMAL,
139            Self::Medium => gpui::FontWeight::MEDIUM,
140            Self::Semibold => gpui::FontWeight::SEMIBOLD,
141            Self::Bold => gpui::FontWeight::BOLD,
142        }
143    }
144}
145
146/// Paragraph size for [`Typography::paragraph`].
147#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
148pub enum ParagraphSize {
149    /// Body size (`TypographyType::Body`).
150    #[default]
151    Base,
152    /// Small size (`TypographyType::BodySm`).
153    Sm,
154    /// Extra-small size (`TypographyType::BodyXs`).
155    Xs,
156}
157
158/// HeroUI Typography.
159#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
160#[derive(IntoElement)]
161pub struct Typography {
162    kind: TypographyType,
163    align: TextAlign,
164    color: TextColor,
165    weight: Option<FontWeight>,
166    truncate: bool,
167    /// The family every kind is drawn with; unset keeps the kind's own
168    /// family (mono for `Code`, inherited otherwise).
169    font_family: Option<SharedString>,
170    /// The `Code` chip's corner radius, in place of the owning `mark_radius`
171    /// helper. `Code` is the only kind that paints a box, so the others
172    /// ignore it.
173    radius: Option<Pixels>,
174    text: Option<SharedString>,
175    children: Vec<AnyElement>,
176    /// The `sx` slot, refined over the root style at the end of render.
177    sx: Option<Box<gpui::StyleRefinement>>,
178}
179
180impl Typography {
181    /// Creates a text element with the given content and default styling.
182    pub fn new(text: impl Into<SharedString>) -> Self {
183        Self {
184            kind: TypographyType::default(),
185            align: TextAlign::default(),
186            color: TextColor::default(),
187            weight: None,
188            truncate: false,
189            font_family: None,
190            radius: None,
191            text: Some(text.into()),
192            children: Vec::new(),
193            sx: None,
194        }
195    }
196
197    /// `Typography.Heading level={1..6}`.
198    pub fn heading(level: u8, text: impl Into<SharedString>) -> Self {
199        Self::new(text).kind(TypographyType::heading(level))
200    }
201
202    /// `Typography.Paragraph size="base" | "sm" | "xs"`.
203    pub fn paragraph(size: ParagraphSize, text: impl Into<SharedString>) -> Self {
204        Self::new(text).kind(match size {
205            ParagraphSize::Base => TypographyType::Body,
206            ParagraphSize::Sm => TypographyType::BodySm,
207            ParagraphSize::Xs => TypographyType::BodyXs,
208        })
209    }
210
211    /// `Typography.Code`.
212    pub fn code(text: impl Into<SharedString>) -> Self {
213        Self::new(text).kind(TypographyType::Code)
214    }
215
216    /// The `type` prop — named `kind` because `type` is a Rust keyword.
217    pub fn kind(mut self, kind: TypographyType) -> Self {
218        self.kind = kind;
219        self
220    }
221
222    /// Sets the text alignment.
223    pub fn align(mut self, align: TextAlign) -> Self {
224        self.align = align;
225        self
226    }
227
228    /// Sets the text colour.
229    pub fn color(mut self, color: TextColor) -> Self {
230        self.color = color;
231        self
232    }
233
234    /// Sets the font weight, overriding the type's default weight.
235    pub fn weight(mut self, weight: FontWeight) -> Self {
236        self.weight = Some(weight);
237        self
238    }
239
240    /// Sets whether overflowing text is truncated.
241    pub fn truncate(mut self, v: bool) -> Self {
242        self.truncate = v;
243        self
244    }
245
246    /// The family the text is drawn with; unset keeps the kind's own family
247    /// (mono for `Code`, inherited otherwise).
248    pub fn font_family(mut self, family: impl Into<SharedString>) -> Self {
249        self.font_family = Some(family.into());
250        self
251    }
252
253    /// The `code` chip's corner radius, in place of the owning `mark_radius`
254    /// helper. `Typography::code` is the only kind that paints a box, so every
255    /// other kind ignores this. Not a v3 prop; the removed v2 `radius` prop is
256    /// prohibited and this is a per-component repository extension.
257    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
258        self.radius = Some(radius.into());
259        self
260    }
261
262    /// The one slot for caller-owned low-level styling: GPUI's styling methods
263    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
264    /// applied to the typography's root element after every value the kind, the
265    /// color and the active theme chose, so they win.
266    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
267        crate::util::refine_sx(&mut self.sx, style);
268        self
269    }
270}
271
272impl ParentElement for Typography {
273    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
274        self.children.extend(elements);
275    }
276}
277
278impl RenderOnce for Typography {
279    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
280        let colors = cx.colors();
281        let (size, line_height) = self.kind.metrics();
282        let weight = self.weight.unwrap_or_else(|| self.kind.default_weight());
283
284        let mut el = div()
285            .text_size(size)
286            .line_height(line_height)
287            .font_weight(weight.to_gpui())
288            .text_color(match self.color {
289                TextColor::Default => colors.foreground,
290                TextColor::Muted => colors.muted,
291            });
292
293        // `typography--code` paints the `rounded-md bg-default px-1.5
294        // py-0.5` chip on top of the mono `text-sm` run.
295        if self.kind.is_mono() {
296            el = el
297                .font_family(MONO_FONT)
298                .bg(colors.default.color)
299                .rounded(self.radius.unwrap_or_else(|| crate::util::mark_radius(cx)))
300                .px(px(6.))
301                .py(px(2.));
302        }
303        if let Some(family) = self.font_family.clone() {
304            el = el.font_family(family);
305        }
306
307        // gpui has no `text-justify`; justify falls back to start alignment.
308        el = match self.align {
309            TextAlign::Start | TextAlign::Justify => el.text_left(),
310            TextAlign::Center => el.text_center(),
311            TextAlign::End => el.text_right(),
312        };
313
314        if self.truncate {
315            el = el.truncate();
316        }
317
318        if let Some(text) = self.text {
319            el = el.child(text.to_string());
320        }
321        el = el.children(self.children);
322        el = crate::util::apply_sx(el, &self.sx);
323        el
324    }
325}
326
327/// `Typography.Prose` — upstream's `.typography-prose` block: a plain
328/// container that sets `text-foreground` and renders its children in order.
329/// The per-tag descendant styles (`p`, `code`, `a`, lists, …) cannot be
330/// ported because GPUI has no ancestor context propagation; children must be
331/// already-semantic elements such as [`Typography`], which carry their own
332/// metrics.
333#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
334#[derive(IntoElement)]
335pub struct Prose {
336    children: Vec<AnyElement>,
337    /// The `sx` slot, refined over the root style at the end of render.
338    sx: Option<Box<gpui::StyleRefinement>>,
339}
340
341impl Prose {
342    /// Creates an empty prose container.
343    pub fn new() -> Self {
344        Self {
345            children: Vec::new(),
346            sx: None,
347        }
348    }
349
350    /// The one slot for caller-owned low-level styling: GPUI's styling methods
351    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
352    /// applied to the prose block's root element after every value the active
353    /// theme chose, so they win.
354    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
355        crate::util::refine_sx(&mut self.sx, style);
356        self
357    }
358}
359
360impl Default for Prose {
361    fn default() -> Self {
362        Self::new()
363    }
364}
365
366impl ParentElement for Prose {
367    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
368        self.children.extend(elements);
369    }
370}
371
372impl RenderOnce for Prose {
373    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
374        let el = div()
375            .text_color(cx.colors().foreground)
376            .children(self.children);
377        crate::util::apply_sx(el, &self.sx)
378    }
379}
380
381crate::util::impl_component_styled!(Typography, Prose);