Introduce `InteractiveText` (#3397)

Antonio Scandurra created

This new element will let us react to click events on arbitrary ranges
of some rendered text, e.g.:

```rs
InteractiveText::new(
    "element-id",
    StyledText::new("Hello world, how is it going?").with_runs(vec![
        cx.text_style().to_run(6),
        TextRun {
            background_color: Some(green()),
            ..cx.text_style().to_run(5)
        },
        cx.text_style().to_run(18),
    ]),
)
.on_click(vec![2..4, 1..3, 7..9], |range_ix, cx| {
    println!("Clicked range {range_ix}");
})
```

As part of this, I also added the ability to give text runs a background
color.

Release Notes:

- N/A

Change summary

crates/editor2/src/editor.rs                |   2 
crates/editor2/src/element.rs               |   2 
crates/gpui2/src/elements/text.rs           | 135 +++++++++++++++++++---
crates/gpui2/src/style.rs                   |  14 ++
crates/gpui2/src/styled.rs                  |   7 +
crates/gpui2/src/text_system.rs             |  11 +
crates/gpui2/src/text_system/line.rs        |  64 ++++++++++
crates/gpui2/src/text_system/line_layout.rs |  35 +++++
crates/gpui2/src/window.rs                  |   3 
crates/storybook2/src/stories/text.rs       |  20 +++
crates/ui2/src/components/label.rs          |   2 
11 files changed, 262 insertions(+), 33 deletions(-)

Detailed changes

crates/editor2/src/editor.rs 🔗

@@ -9423,6 +9423,7 @@ impl Render for Editor {
                 font_weight: FontWeight::NORMAL,
                 font_style: FontStyle::Normal,
                 line_height: relative(1.).into(),
+                background_color: None,
                 underline: None,
                 white_space: WhiteSpace::Normal,
             },
@@ -9437,6 +9438,7 @@ impl Render for Editor {
                 font_weight: FontWeight::NORMAL,
                 font_style: FontStyle::Normal,
                 line_height: relative(settings.buffer_line_height.value()),
+                background_color: None,
                 underline: None,
                 white_space: WhiteSpace::Normal,
             },

crates/editor2/src/element.rs 🔗

@@ -2452,7 +2452,7 @@ impl LineWithInvisibles {
                         len: line_chunk.len(),
                         font: text_style.font(),
                         color: text_style.color,
-                        background_color: None,
+                        background_color: text_style.background_color,
                         underline: text_style.underline,
                     });
 

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

@@ -1,11 +1,11 @@
 use crate::{
-    Bounds, Element, ElementId, IntoElement, LayoutId, Pixels, SharedString, Size, TextRun,
-    WhiteSpace, WindowContext, WrappedLine,
+    Bounds, DispatchPhase, Element, ElementId, IntoElement, LayoutId, MouseDownEvent, MouseUpEvent,
+    Pixels, Point, SharedString, Size, TextRun, WhiteSpace, WindowContext, WrappedLine,
 };
 use anyhow::anyhow;
 use parking_lot::{Mutex, MutexGuard};
 use smallvec::SmallVec;
-use std::{cell::Cell, rc::Rc, sync::Arc};
+use std::{cell::Cell, ops::Range, rc::Rc, sync::Arc};
 use util::ResultExt;
 
 impl Element for &'static str {
@@ -69,23 +69,28 @@ impl IntoElement for SharedString {
     }
 }
 
+/// Renders text with runs of different styles.
+///
+/// Callers are responsible for setting the correct style for each run.
+/// For text with a uniform style, you can usually avoid calling this constructor
+/// and just pass text directly.
 pub struct StyledText {
     text: SharedString,
     runs: Option<Vec<TextRun>>,
 }
 
 impl StyledText {
-    /// Renders text with runs of different styles.
-    ///
-    /// Callers are responsible for setting the correct style for each run.
-    /// For text with a uniform style, you can usually avoid calling this constructor
-    /// and just pass text directly.
-    pub fn new(text: SharedString, runs: Vec<TextRun>) -> Self {
+    pub fn new(text: impl Into<SharedString>) -> Self {
         StyledText {
-            text,
-            runs: Some(runs),
+            text: text.into(),
+            runs: None,
         }
     }
+
+    pub fn with_runs(mut self, runs: Vec<TextRun>) -> Self {
+        self.runs = Some(runs);
+        self
+    }
 }
 
 impl Element for StyledText {
@@ -226,16 +231,73 @@ impl TextState {
             line_origin.y += line.size(line_height).height;
         }
     }
+
+    fn index_for_position(&self, bounds: Bounds<Pixels>, position: Point<Pixels>) -> Option<usize> {
+        if !bounds.contains_point(&position) {
+            return None;
+        }
+
+        let element_state = self.lock();
+        let element_state = element_state
+            .as_ref()
+            .expect("measurement has not been performed");
+
+        let line_height = element_state.line_height;
+        let mut line_origin = bounds.origin;
+        for line in &element_state.lines {
+            let line_bottom = line_origin.y + line.size(line_height).height;
+            if position.y > line_bottom {
+                line_origin.y = line_bottom;
+            } else {
+                let position_within_line = position - line_origin;
+                return line.index_for_position(position_within_line, line_height);
+            }
+        }
+
+        None
+    }
 }
 
-struct InteractiveText {
+pub struct InteractiveText {
     element_id: ElementId,
     text: StyledText,
+    click_listener: Option<Box<dyn Fn(InteractiveTextClickEvent, &mut WindowContext<'_>)>>,
 }
 
-struct InteractiveTextState {
+struct InteractiveTextClickEvent {
+    mouse_down_index: usize,
+    mouse_up_index: usize,
+}
+
+pub struct InteractiveTextState {
     text_state: TextState,
-    clicked_range_ixs: Rc<Cell<SmallVec<[usize; 1]>>>,
+    mouse_down_index: Rc<Cell<Option<usize>>>,
+}
+
+impl InteractiveText {
+    pub fn new(id: impl Into<ElementId>, text: StyledText) -> Self {
+        Self {
+            element_id: id.into(),
+            text,
+            click_listener: None,
+        }
+    }
+
+    pub fn on_click(
+        mut self,
+        ranges: Vec<Range<usize>>,
+        listener: impl Fn(usize, &mut WindowContext<'_>) + 'static,
+    ) -> Self {
+        self.click_listener = Some(Box::new(move |event, cx| {
+            for (range_ix, range) in ranges.iter().enumerate() {
+                if range.contains(&event.mouse_down_index) && range.contains(&event.mouse_up_index)
+                {
+                    listener(range_ix, cx);
+                }
+            }
+        }));
+        self
+    }
 }
 
 impl Element for InteractiveText {
@@ -247,27 +309,62 @@ impl Element for InteractiveText {
         cx: &mut WindowContext,
     ) -> (LayoutId, Self::State) {
         if let Some(InteractiveTextState {
-            text_state,
-            clicked_range_ixs,
+            mouse_down_index, ..
         }) = state
         {
-            let (layout_id, text_state) = self.text.layout(Some(text_state), cx);
+            let (layout_id, text_state) = self.text.layout(None, cx);
             let element_state = InteractiveTextState {
                 text_state,
-                clicked_range_ixs,
+                mouse_down_index,
             };
             (layout_id, element_state)
         } else {
             let (layout_id, text_state) = self.text.layout(None, cx);
             let element_state = InteractiveTextState {
                 text_state,
-                clicked_range_ixs: Rc::default(),
+                mouse_down_index: Rc::default(),
             };
             (layout_id, element_state)
         }
     }
 
     fn paint(self, bounds: Bounds<Pixels>, state: &mut Self::State, cx: &mut WindowContext) {
+        if let Some(click_listener) = self.click_listener {
+            let text_state = state.text_state.clone();
+            let mouse_down = state.mouse_down_index.clone();
+            if let Some(mouse_down_index) = mouse_down.get() {
+                cx.on_mouse_event(move |event: &MouseUpEvent, phase, cx| {
+                    if phase == DispatchPhase::Bubble {
+                        if let Some(mouse_up_index) =
+                            text_state.index_for_position(bounds, event.position)
+                        {
+                            click_listener(
+                                InteractiveTextClickEvent {
+                                    mouse_down_index,
+                                    mouse_up_index,
+                                },
+                                cx,
+                            )
+                        }
+
+                        mouse_down.take();
+                        cx.notify();
+                    }
+                });
+            } else {
+                cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| {
+                    if phase == DispatchPhase::Bubble {
+                        if let Some(mouse_down_index) =
+                            text_state.index_for_position(bounds, event.position)
+                        {
+                            mouse_down.set(Some(mouse_down_index));
+                            cx.notify();
+                        }
+                    }
+                });
+            }
+        }
+
         self.text.paint(bounds, &mut state.text_state, cx)
     }
 }

crates/gpui2/src/style.rs 🔗

@@ -145,6 +145,7 @@ pub struct TextStyle {
     pub line_height: DefiniteLength,
     pub font_weight: FontWeight,
     pub font_style: FontStyle,
+    pub background_color: Option<Hsla>,
     pub underline: Option<UnderlineStyle>,
     pub white_space: WhiteSpace,
 }
@@ -159,6 +160,7 @@ impl Default for TextStyle {
             line_height: phi(),
             font_weight: FontWeight::default(),
             font_style: FontStyle::default(),
+            background_color: None,
             underline: None,
             white_space: WhiteSpace::Normal,
         }
@@ -182,6 +184,10 @@ impl TextStyle {
             self.color.fade_out(factor);
         }
 
+        if let Some(background_color) = style.background_color {
+            self.background_color = Some(background_color);
+        }
+
         if let Some(underline) = style.underline {
             self.underline = Some(underline);
         }
@@ -212,7 +218,7 @@ impl TextStyle {
                 style: self.font_style,
             },
             color: self.color,
-            background_color: None,
+            background_color: self.background_color,
             underline: self.underline.clone(),
         }
     }
@@ -223,6 +229,7 @@ pub struct HighlightStyle {
     pub color: Option<Hsla>,
     pub font_weight: Option<FontWeight>,
     pub font_style: Option<FontStyle>,
+    pub background_color: Option<Hsla>,
     pub underline: Option<UnderlineStyle>,
     pub fade_out: Option<f32>,
 }
@@ -441,6 +448,7 @@ impl From<&TextStyle> for HighlightStyle {
             color: Some(other.color),
             font_weight: Some(other.font_weight),
             font_style: Some(other.font_style),
+            background_color: other.background_color,
             underline: other.underline.clone(),
             fade_out: None,
         }
@@ -467,6 +475,10 @@ impl HighlightStyle {
             self.font_style = other.font_style;
         }
 
+        if other.background_color.is_some() {
+            self.background_color = other.background_color;
+        }
+
         if other.underline.is_some() {
             self.underline = other.underline;
         }

crates/gpui2/src/styled.rs 🔗

@@ -361,6 +361,13 @@ pub trait Styled: Sized {
         self
     }
 
+    fn text_bg(mut self, bg: impl Into<Hsla>) -> Self {
+        self.text_style()
+            .get_or_insert_with(Default::default)
+            .background_color = Some(bg.into());
+        self
+    }
+
     fn text_size(mut self, size: impl Into<AbsoluteLength>) -> Self {
         self.text_style()
             .get_or_insert_with(Default::default)

crates/gpui2/src/text_system.rs 🔗

@@ -196,7 +196,10 @@ impl TextSystem {
         let mut decoration_runs = SmallVec::<[DecorationRun; 32]>::new();
         for run in runs {
             if let Some(last_run) = decoration_runs.last_mut() {
-                if last_run.color == run.color && last_run.underline == run.underline {
+                if last_run.color == run.color
+                    && last_run.underline == run.underline
+                    && last_run.background_color == run.background_color
+                {
                     last_run.len += run.len as u32;
                     continue;
                 }
@@ -204,6 +207,7 @@ impl TextSystem {
             decoration_runs.push(DecorationRun {
                 len: run.len as u32,
                 color: run.color,
+                background_color: run.background_color,
                 underline: run.underline.clone(),
             });
         }
@@ -254,13 +258,16 @@ impl TextSystem {
                 }
 
                 if decoration_runs.last().map_or(false, |last_run| {
-                    last_run.color == run.color && last_run.underline == run.underline
+                    last_run.color == run.color
+                        && last_run.underline == run.underline
+                        && last_run.background_color == run.background_color
                 }) {
                     decoration_runs.last_mut().unwrap().len += run_len_within_line as u32;
                 } else {
                     decoration_runs.push(DecorationRun {
                         len: run_len_within_line as u32,
                         color: run.color,
+                        background_color: run.background_color,
                         underline: run.underline.clone(),
                     });
                 }

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

@@ -1,6 +1,7 @@
 use crate::{
-    black, point, px, BorrowWindow, Bounds, Hsla, LineLayout, Pixels, Point, Result, SharedString,
-    UnderlineStyle, WindowContext, WrapBoundary, WrappedLineLayout,
+    black, point, px, size, transparent_black, BorrowWindow, Bounds, Corners, Edges, Hsla,
+    LineLayout, Pixels, Point, Result, SharedString, UnderlineStyle, WindowContext, WrapBoundary,
+    WrappedLineLayout,
 };
 use derive_more::{Deref, DerefMut};
 use smallvec::SmallVec;
@@ -10,6 +11,7 @@ use std::sync::Arc;
 pub struct DecorationRun {
     pub len: u32,
     pub color: Hsla,
+    pub background_color: Option<Hsla>,
     pub underline: Option<UnderlineStyle>,
 }
 
@@ -97,6 +99,7 @@ fn paint_line(
     let mut run_end = 0;
     let mut color = black();
     let mut current_underline: Option<(Point<Pixels>, UnderlineStyle)> = None;
+    let mut current_background: Option<(Point<Pixels>, Hsla)> = None;
     let text_system = cx.text_system().clone();
     let mut glyph_origin = origin;
     let mut prev_glyph_position = Point::default();
@@ -110,12 +113,24 @@ fn paint_line(
 
             if wraps.peek() == Some(&&WrapBoundary { run_ix, glyph_ix }) {
                 wraps.next();
+                if let Some((background_origin, background_color)) = current_background.take() {
+                    cx.paint_quad(
+                        Bounds {
+                            origin: background_origin,
+                            size: size(glyph_origin.x - background_origin.x, line_height),
+                        },
+                        Corners::default(),
+                        background_color,
+                        Edges::default(),
+                        transparent_black(),
+                    );
+                }
                 if let Some((underline_origin, underline_style)) = current_underline.take() {
                     cx.paint_underline(
                         underline_origin,
                         glyph_origin.x - underline_origin.x,
                         &underline_style,
-                    )?;
+                    );
                 }
 
                 glyph_origin.x = origin.x;
@@ -123,9 +138,20 @@ fn paint_line(
             }
             prev_glyph_position = glyph.position;
 
+            let mut finished_background: Option<(Point<Pixels>, Hsla)> = None;
             let mut finished_underline: Option<(Point<Pixels>, UnderlineStyle)> = None;
             if glyph.index >= run_end {
                 if let Some(style_run) = decoration_runs.next() {
+                    if let Some((_, background_color)) = &mut current_background {
+                        if style_run.background_color.as_ref() != Some(background_color) {
+                            finished_background = current_background.take();
+                        }
+                    }
+                    if let Some(run_background) = style_run.background_color {
+                        current_background
+                            .get_or_insert((point(glyph_origin.x, origin.y), run_background));
+                    }
+
                     if let Some((_, underline_style)) = &mut current_underline {
                         if style_run.underline.as_ref() != Some(underline_style) {
                             finished_underline = current_underline.take();
@@ -149,16 +175,30 @@ fn paint_line(
                     color = style_run.color;
                 } else {
                     run_end = layout.len;
+                    finished_background = current_background.take();
                     finished_underline = current_underline.take();
                 }
             }
 
+            if let Some((background_origin, background_color)) = finished_background {
+                cx.paint_quad(
+                    Bounds {
+                        origin: background_origin,
+                        size: size(glyph_origin.x - background_origin.x, line_height),
+                    },
+                    Corners::default(),
+                    background_color,
+                    Edges::default(),
+                    transparent_black(),
+                );
+            }
+
             if let Some((underline_origin, underline_style)) = finished_underline {
                 cx.paint_underline(
                     underline_origin,
                     glyph_origin.x - underline_origin.x,
                     &underline_style,
-                )?;
+                );
             }
 
             let max_glyph_bounds = Bounds {
@@ -188,13 +228,27 @@ fn paint_line(
         }
     }
 
+    if let Some((background_origin, background_color)) = current_background.take() {
+        let line_end_x = origin.x + wrap_width.unwrap_or(Pixels::MAX).min(layout.width);
+        cx.paint_quad(
+            Bounds {
+                origin: background_origin,
+                size: size(line_end_x - background_origin.x, line_height),
+            },
+            Corners::default(),
+            background_color,
+            Edges::default(),
+            transparent_black(),
+        );
+    }
+
     if let Some((underline_start, underline_style)) = current_underline.take() {
         let line_end_x = origin.x + wrap_width.unwrap_or(Pixels::MAX).min(layout.width);
         cx.paint_underline(
             underline_start,
             line_end_x - underline_start.x,
             &underline_style,
-        )?;
+        );
     }
 
     Ok(())

crates/gpui2/src/text_system/line_layout.rs 🔗

@@ -198,6 +198,41 @@ impl WrappedLineLayout {
     pub fn runs(&self) -> &[ShapedRun] {
         &self.unwrapped_layout.runs
     }
+
+    pub fn index_for_position(
+        &self,
+        position: Point<Pixels>,
+        line_height: Pixels,
+    ) -> Option<usize> {
+        let wrapped_line_ix = (position.y / line_height) as usize;
+
+        let wrapped_line_start_x = if wrapped_line_ix > 0 {
+            let wrap_boundary_ix = wrapped_line_ix - 1;
+            let wrap_boundary = self.wrap_boundaries[wrap_boundary_ix];
+            let run = &self.unwrapped_layout.runs[wrap_boundary.run_ix];
+            run.glyphs[wrap_boundary.glyph_ix].position.x
+        } else {
+            Pixels::ZERO
+        };
+
+        let wrapped_line_end_x = if wrapped_line_ix < self.wrap_boundaries.len() {
+            let next_wrap_boundary_ix = wrapped_line_ix;
+            let next_wrap_boundary = self.wrap_boundaries[next_wrap_boundary_ix];
+            let run = &self.unwrapped_layout.runs[next_wrap_boundary.run_ix];
+            run.glyphs[next_wrap_boundary.glyph_ix].position.x
+        } else {
+            self.unwrapped_layout.width
+        };
+
+        let mut position_in_unwrapped_line = position;
+        position_in_unwrapped_line.x += wrapped_line_start_x;
+        if position_in_unwrapped_line.x > wrapped_line_end_x {
+            None
+        } else {
+            self.unwrapped_layout
+                .index_for_x(position_in_unwrapped_line.x)
+        }
+    }
 }
 
 pub(crate) struct LineLayoutCache {

crates/gpui2/src/window.rs 🔗

@@ -881,7 +881,7 @@ impl<'a> WindowContext<'a> {
         origin: Point<Pixels>,
         width: Pixels,
         style: &UnderlineStyle,
-    ) -> Result<()> {
+    ) {
         let scale_factor = self.scale_factor();
         let height = if style.wavy {
             style.thickness * 3.
@@ -905,7 +905,6 @@ impl<'a> WindowContext<'a> {
                 wavy: style.wavy,
             },
         );
-        Ok(())
     }
 
     /// Paint a monochrome (non-emoji) glyph into the scene for the current frame at the current z-index.

crates/storybook2/src/stories/text.rs 🔗

@@ -1,5 +1,6 @@
 use gpui::{
-    blue, div, red, white, Div, ParentElement, Render, Styled, View, VisualContext, WindowContext,
+    blue, div, green, red, white, Div, InteractiveText, ParentElement, Render, Styled, StyledText,
+    TextRun, View, VisualContext, WindowContext,
 };
 use ui::v_stack;
 
@@ -55,6 +56,21 @@ impl Render for TextStory {
                 "flex-row. width 96. The quick brown fox jumps over the lazy dog. ",
                 "Meanwhile, the lazy dog decided it was time for a change. ",
                 "He started daily workout routines, ate healthier and became the fastest dog in town.",
-            )))
+            ))).child(
+                InteractiveText::new(
+                    "interactive",
+                    StyledText::new("Hello world, how is it going?").with_runs(vec![
+                        cx.text_style().to_run(6),
+                        TextRun {
+                            background_color: Some(green()),
+                            ..cx.text_style().to_run(5)
+                        },
+                        cx.text_style().to_run(18),
+                    ]),
+                )
+                .on_click(vec![2..4, 1..3, 7..9], |range_ix, cx| {
+                    println!("Clicked range {range_ix}");
+                })
+            )
     }
 }

crates/ui2/src/components/label.rs 🔗

@@ -150,7 +150,7 @@ impl RenderOnce for HighlightedLabel {
                 LabelSize::Default => this.text_ui(),
                 LabelSize::Small => this.text_ui_sm(),
             })
-            .child(StyledText::new(self.label, runs))
+            .child(StyledText::new(self.label).with_runs(runs))
     }
 }