gpui: Add text alignment (#24090)

someone13574 created

Adds a text property for controlling left, center, or right text
alignment.

#8792 should stay open since this doesn't add support for `justify`
(which would require a much bigger change since this can just alter the
origin of each line, but justify requires changing spacing, whereas
justify requires changes to each platform's shaping code).

Release Notes:

- N/A

Change summary

crates/gpui/src/elements/div.rs     |  5 +
crates/gpui/src/elements/text.rs    |  4 +
crates/gpui/src/style.rs            | 19 +++++++++
crates/gpui/src/styled.rs           | 25 +++++++++++
crates/gpui/src/text_system/line.rs | 65 +++++++++++++++++++++++++++++-
5 files changed, 112 insertions(+), 6 deletions(-)

Detailed changes

crates/gpui/src/elements/div.rs 🔗

@@ -1661,6 +1661,8 @@ impl Interactivity {
         window: &mut Window,
         cx: &mut App,
     ) {
+        use crate::TextAlign;
+
         if global_id.is_some()
             && (style.debug || style.debug_below || cx.has_global::<crate::DebugBelow>())
             && hitbox.is_hovered(window)
@@ -1682,7 +1684,8 @@ impl Interactivity {
                     .ok()
                     .and_then(|mut text| text.pop())
                 {
-                    text.paint(hitbox.origin, FONT_SIZE, window, cx).ok();
+                    text.paint(hitbox.origin, FONT_SIZE, TextAlign::Left, window, cx)
+                        .ok();
 
                     let text_bounds = crate::Bounds {
                         origin: hitbox.origin,

crates/gpui/src/elements/text.rs 🔗

@@ -390,8 +390,10 @@ impl TextLayout {
 
         let line_height = element_state.line_height;
         let mut line_origin = bounds.origin;
+        let text_style = window.text_style();
         for line in &element_state.lines {
-            line.paint(line_origin, line_height, window, cx).log_err();
+            line.paint(line_origin, line_height, text_style.text_align, window, cx)
+                .log_err();
             line_origin.y += line.size(line_height).height;
         }
     }

crates/gpui/src/style.rs 🔗

@@ -293,6 +293,20 @@ pub enum TextOverflow {
     Ellipsis(&'static str),
 }
 
+/// How to align text within the element
+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
+pub enum TextAlign {
+    /// Align the text to the left of the element
+    #[default]
+    Left,
+
+    /// Center the text within the element
+    Center,
+
+    /// Align the text to the right of the element
+    Right,
+}
+
 /// The properties that can be used to style text in GPUI
 #[derive(Refineable, Clone, Debug, PartialEq)]
 #[refineable(Debug)]
@@ -335,6 +349,10 @@ pub struct TextStyle {
 
     /// The text should be truncated if it overflows the width of the element
     pub text_overflow: Option<TextOverflow>,
+
+    /// How the text should be aligned within the element
+    pub text_align: TextAlign,
+
     /// The number of lines to display before truncating the text
     pub line_clamp: Option<usize>,
 }
@@ -362,6 +380,7 @@ impl Default for TextStyle {
             strikethrough: None,
             white_space: WhiteSpace::Normal,
             text_overflow: None,
+            text_align: TextAlign::default(),
             line_clamp: None,
         }
     }

crates/gpui/src/styled.rs 🔗

@@ -1,9 +1,9 @@
-use crate::TextStyleRefinement;
 use crate::{
     self as gpui, px, relative, rems, AbsoluteLength, AlignItems, CursorStyle, DefiniteLength,
     Fill, FlexDirection, FlexWrap, Font, FontStyle, FontWeight, Hsla, JustifyContent, Length,
     SharedString, StrikethroughStyle, StyleRefinement, TextOverflow, WhiteSpace,
 };
+use crate::{TextAlign, TextStyleRefinement};
 pub use gpui_macros::{
     border_style_methods, box_shadow_style_methods, cursor_style_methods, margin_style_methods,
     overflow_style_methods, padding_style_methods, position_style_methods,
@@ -78,6 +78,29 @@ pub trait Styled: Sized {
         self
     }
 
+    /// Set the text alignment of the element.
+    fn text_align(mut self, align: TextAlign) -> Self {
+        self.text_style()
+            .get_or_insert_with(Default::default)
+            .text_align = Some(align);
+        self
+    }
+
+    /// Sets the text alignment to left
+    fn text_left(mut self) -> Self {
+        self.text_align(TextAlign::Left)
+    }
+
+    /// Sets the text alignment to center
+    fn text_center(mut self) -> Self {
+        self.text_align(TextAlign::Center)
+    }
+
+    /// Sets the text alignment to right
+    fn text_right(mut self) -> Self {
+        self.text_align(TextAlign::Right)
+    }
+
     /// Sets the truncate to prevent text from wrapping and truncate overflowing text with an ellipsis (…) if needed.
     /// [Docs](https://tailwindcss.com/docs/text-overflow#truncate)
     fn truncate(mut self) -> Self {

crates/gpui/src/text_system/line.rs 🔗

@@ -1,6 +1,7 @@
 use crate::{
     black, fill, point, px, size, App, Bounds, Half, Hsla, LineLayout, Pixels, Point, Result,
-    SharedString, StrikethroughStyle, UnderlineStyle, Window, WrapBoundary, WrappedLineLayout,
+    SharedString, StrikethroughStyle, TextAlign, UnderlineStyle, Window, WrapBoundary,
+    WrappedLineLayout,
 };
 use derive_more::{Deref, DerefMut};
 use smallvec::SmallVec;
@@ -70,6 +71,8 @@ impl ShapedLine {
             origin,
             &self.layout,
             line_height,
+            TextAlign::default(),
+            None,
             &self.decoration_runs,
             &[],
             window,
@@ -103,6 +106,7 @@ impl WrappedLine {
         &self,
         origin: Point<Pixels>,
         line_height: Pixels,
+        align: TextAlign,
         window: &mut Window,
         cx: &mut App,
     ) -> Result<()> {
@@ -110,6 +114,8 @@ impl WrappedLine {
             origin,
             &self.layout.unwrapped_layout,
             line_height,
+            align,
+            self.layout.wrap_width,
             &self.decoration_runs,
             &self.wrap_boundaries,
             window,
@@ -120,10 +126,13 @@ impl WrappedLine {
     }
 }
 
+#[allow(clippy::too_many_arguments)]
 fn paint_line(
     origin: Point<Pixels>,
     layout: &LineLayout,
     line_height: Pixels,
+    align: TextAlign,
+    align_width: Option<Pixels>,
     decoration_runs: &[DecorationRun],
     wrap_boundaries: &[WrapBoundary],
     window: &mut Window,
@@ -147,7 +156,17 @@ fn paint_line(
         let mut current_strikethrough: Option<(Point<Pixels>, StrikethroughStyle)> = None;
         let mut current_background: Option<(Point<Pixels>, Hsla)> = None;
         let text_system = cx.text_system().clone();
-        let mut glyph_origin = origin;
+        let mut glyph_origin = point(
+            aligned_origin_x(
+                origin,
+                align_width.unwrap_or(layout.width),
+                px(0.0),
+                &align,
+                layout,
+                wraps.peek(),
+            ),
+            origin.y,
+        );
         let mut prev_glyph_position = Point::default();
         let mut max_glyph_size = size(px(0.), px(0.));
         for (run_ix, run) in layout.runs.iter().enumerate() {
@@ -200,7 +219,14 @@ fn paint_line(
                         strikethrough_origin.y += line_height;
                     }
 
-                    glyph_origin.x = origin.x;
+                    glyph_origin.x = aligned_origin_x(
+                        origin,
+                        align_width.unwrap_or(layout.width),
+                        prev_glyph_position.x,
+                        &align,
+                        layout,
+                        wraps.peek(),
+                    );
                     glyph_origin.y += line_height;
                 }
                 prev_glyph_position = glyph.position;
@@ -390,3 +416,36 @@ fn paint_line(
         Ok(())
     })
 }
+
+fn aligned_origin_x(
+    origin: Point<Pixels>,
+    align_width: Pixels,
+    last_glyph_x: Pixels,
+    align: &TextAlign,
+    layout: &LineLayout,
+    wrap_boundary: Option<&&WrapBoundary>,
+) -> Pixels {
+    let end_of_line = if let Some(WrapBoundary { run_ix, glyph_ix }) = wrap_boundary {
+        if layout.runs[*run_ix].glyphs.len() == glyph_ix + 1 {
+            // Next glyph is in next run
+            layout
+                .runs
+                .get(run_ix + 1)
+                .and_then(|run| run.glyphs.first())
+                .map_or(layout.width, |glyph| glyph.position.x)
+        } else {
+            // Get next glyph
+            layout.runs[*run_ix].glyphs[*glyph_ix + 1].position.x
+        }
+    } else {
+        layout.width
+    };
+
+    let line_width = end_of_line - last_glyph_x;
+
+    match align {
+        TextAlign::Left => origin.x,
+        TextAlign::Center => (2.0 * origin.x + align_width - line_width) / 2.0,
+        TextAlign::Right => origin.x + align_width - line_width,
+    }
+}