Allow finding which ranges were clicked on an `InteractiveText`

Antonio Scandurra created

Change summary

crates/gpui2/src/elements/text.rs           | 135 +++++++++++++++++++---
crates/gpui2/src/text_system/line_layout.rs |  35 +++++
crates/storybook2/src/stories/text.rs       |   5 
crates/ui2/src/components/label.rs          |   2 
4 files changed, 155 insertions(+), 22 deletions(-)

Detailed changes

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/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/storybook2/src/stories/text.rs 🔗

@@ -1,5 +1,6 @@
 use gpui::{
-    blue, div, red, white, Div, ParentElement, Render, Styled, View, VisualContext, WindowContext,
+    blue, div, red, white, Div, InteractiveText, ParentElement, Render, Styled, StyledText, View,
+    VisualContext, WindowContext,
 };
 use ui::v_stack;
 
@@ -55,6 +56,6 @@ 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?")).on_click(vec![2..4], |event, cx| {dbg!(event);}))
     }
 }

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))
     }
 }