gpui: Fix text hover & active style (#24723)

Jason Lee and Mikayla Maki created

Release Notes:

- N/A

---

Fix this long-standing issue so that we can support Link hover colors.

And renamed `text_layout` example to `text_style`.

---

I spent some time studying the process of this text style change and
found it a bit complicated.

At first, I thought there was a problem with refine and it was not
passed properly. After changing it, I found that it was not the problem.

Then I found that it was because `TextRun` had already stored the
`color`, `background`, `underline`, `strikethrough` in TextRun in the
`request_layout` stage. They area calculate at the `request_layout`
stage, but request_layout stage there was no `hitbox`, so the hover
state was not obtained.

```bash
cargo run -p gpui --example text_style
```


https://github.com/user-attachments/assets/24f88f73-775e-41d3-a502-75a7a39ac82b

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>

Change summary

crates/gpui/examples/text_style.rs  | 20 +++++++++++++
crates/gpui/src/elements/div.rs     |  4 +-
crates/gpui/src/elements/text.rs    |  4 +
crates/gpui/src/text_system/line.rs | 47 +++++++++++++++++++++---------
4 files changed, 58 insertions(+), 17 deletions(-)

Detailed changes

crates/gpui/examples/text_layout.rs → crates/gpui/examples/text_style.rs 🔗

@@ -8,11 +8,13 @@ struct HelloWorld {}
 impl Render for HelloWorld {
     fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
         div()
+            .font_family(".SystemUIFont")
             .bg(gpui::white())
             .flex()
             .flex_col()
             .gap_2()
             .p_4()
+            .gap_4()
             .size_full()
             .child(div().child("Text left"))
             .child(div().text_center().child("Text center"))
@@ -71,6 +73,24 @@ impl Render for HelloWorld {
                             .child("100%"),
                     ),
             )
+            .child(
+                div()
+                    .id("Text Link")
+                    .text_color(gpui::blue())
+                    .cursor_pointer()
+                    .active(|this| {
+                        this.text_color(gpui::white())
+                            .bg(gpui::blue())
+                            .text_decoration_1()
+                            .text_decoration_wavy()
+                    })
+                    .hover(|this| {
+                        this.text_color(gpui::rgb(0x973717))
+                            .bg(gpui::yellow())
+                            .text_decoration_1()
+                    })
+                    .child("Text with hover, active styles"),
+            )
     }
 }
 

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

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

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

@@ -415,7 +415,9 @@ 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_background(
                 line_origin,
@@ -429,7 +431,7 @@ impl TextLayout {
             line.paint(
                 line_origin,
                 line_height,
-                text_style.text_align,
+                Some(&text_style),
                 Some(bounds),
                 window,
                 cx,

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

@@ -1,7 +1,7 @@
 use crate::{
     App, Bounds, Half, Hsla, LineLayout, Pixels, Point, Result, SharedString, StrikethroughStyle,
-    TextAlign, UnderlineStyle, Window, WrapBoundary, WrappedLineLayout, black, fill, point, px,
-    size,
+    TextAlign, TextStyle, UnderlineStyle, Window, WrapBoundary, WrappedLineLayout, black, fill,
+    point, px, size,
 };
 use derive_more::{Deref, DerefMut};
 use smallvec::SmallVec;
@@ -71,7 +71,7 @@ impl ShapedLine {
             origin,
             &self.layout,
             line_height,
-            TextAlign::default(),
+            None,
             None,
             &self.decoration_runs,
             &[],
@@ -125,11 +125,12 @@ impl WrappedLine {
     }
 
     /// Paint this line of text to the window.
+    #[allow(clippy::too_many_arguments)]
     pub fn paint(
         &self,
         origin: Point<Pixels>,
         line_height: Pixels,
-        align: TextAlign,
+        text_style: Option<&TextStyle>,
         bounds: Option<Bounds<Pixels>>,
         window: &mut Window,
         cx: &mut App,
@@ -143,7 +144,7 @@ impl WrappedLine {
             origin,
             &self.layout.unwrapped_layout,
             line_height,
-            align,
+            text_style,
             align_width,
             &self.decoration_runs,
             &self.wrap_boundaries,
@@ -189,7 +190,7 @@ fn paint_line(
     origin: Point<Pixels>,
     layout: &LineLayout,
     line_height: Pixels,
-    align: TextAlign,
+    text_style: Option<&TextStyle>,
     align_width: Option<Pixels>,
     decoration_runs: &[DecorationRun],
     wrap_boundaries: &[WrapBoundary],
@@ -203,6 +204,10 @@ fn paint_line(
             line_height * (wrap_boundaries.len() as f32 + 1.),
         ),
     );
+
+    // TODO: text_align and line_height need to inherit from normal style when is hovered or activated.
+    let mut text_align = text_style.map(|s| s.text_align).unwrap_or(TextAlign::Left);
+
     window.paint_layer(line_bounds, |window| {
         let padding_top = (line_height - layout.ascent - layout.descent) / 2.;
         let baseline_offset = point(px(0.), padding_top + layout.ascent);
@@ -218,7 +223,7 @@ fn paint_line(
                 origin,
                 align_width.unwrap_or(layout.width),
                 px(0.0),
-                &align,
+                &text_align,
                 layout,
                 wraps.peek(),
             ),
@@ -269,7 +274,7 @@ fn paint_line(
                         origin,
                         align_width.unwrap_or(layout.width),
                         glyph.position.x,
-                        &align,
+                        &text_align,
                         layout,
                         wraps.peek(),
                     );
@@ -292,30 +297,44 @@ fn paint_line(
                     }
 
                     if let Some(style_run) = style_run {
+                        let mut run_color = style_run.color;
+                        let mut run_underline = style_run.underline.as_ref();
+                        let mut run_strikethrough = style_run.strikethrough;
+                        // Override by text run by current style when hovered or activated.
+                        if let Some(val) = text_style.map(|s| s.color) {
+                            run_color = val;
+                        }
+                        if let Some(val) = text_style.and_then(|s| s.underline.as_ref()) {
+                            run_underline = Some(val);
+                        }
+                        if let Some(val) = text_style.and_then(|s| s.strikethrough) {
+                            run_strikethrough = Some(val);
+                        }
+
                         if let Some((_, underline_style)) = &mut current_underline {
                             if style_run.underline.as_ref() != Some(underline_style) {
                                 finished_underline = current_underline.take();
                             }
                         }
-                        if let Some(run_underline) = style_run.underline.as_ref() {
+                        if let Some(run_underline) = run_underline.as_ref() {
                             current_underline.get_or_insert((
                                 point(
                                     glyph_origin.x,
                                     glyph_origin.y + baseline_offset.y + (layout.descent * 0.618),
                                 ),
                                 UnderlineStyle {
-                                    color: Some(run_underline.color.unwrap_or(style_run.color)),
+                                    color: Some(run_underline.color.unwrap_or(run_color)),
                                     thickness: run_underline.thickness,
                                     wavy: run_underline.wavy,
                                 },
                             ));
                         }
                         if let Some((_, strikethrough_style)) = &mut current_strikethrough {
-                            if style_run.strikethrough.as_ref() != Some(strikethrough_style) {
+                            if run_strikethrough.as_ref() != Some(strikethrough_style) {
                                 finished_strikethrough = current_strikethrough.take();
                             }
                         }
-                        if let Some(run_strikethrough) = style_run.strikethrough.as_ref() {
+                        if let Some(mut run_strikethrough) = run_strikethrough.as_ref() {
                             current_strikethrough.get_or_insert((
                                 point(
                                     glyph_origin.x,
@@ -323,14 +342,14 @@ fn paint_line(
                                         + (((layout.ascent * 0.5) + baseline_offset.y) * 0.5),
                                 ),
                                 StrikethroughStyle {
-                                    color: Some(run_strikethrough.color.unwrap_or(style_run.color)),
+                                    color: Some(run_strikethrough.color.unwrap_or(run_color)),
                                     thickness: run_strikethrough.thickness,
                                 },
                             ));
                         }
 
                         run_end += style_run.len as usize;
-                        color = style_run.color;
+                        color = run_color;
                     } else {
                         run_end = layout.len;
                         finished_underline = current_underline.take();