History

Introduce `InteractiveText` (#3397)

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

Antonio Scandurra committed 2 years ago

Commit 510320b2 parents df7b89b 7eeb80711 files · +262 −33Browse files

Unified · Split

11 files
  1. crates/editor2/src/editor.rs +2 −0
  2. crates/editor2/src/element.rs +1 −1
  3. crates/gpui2/src/elements/text.rs +116 −19
  4. crates/gpui2/src/style.rs +13 −1
  5. crates/gpui2/src/styled.rs +7 −0
  6. crates/gpui2/src/text_system.rs +9 −2
  7. crates/gpui2/src/text_system/line.rs +59 −5
  8. crates/gpui2/src/text_system/line_layout.rs +35 −0
  9. crates/gpui2/src/window.rs +1 −2
  10. crates/storybook2/src/stories/text.rs +18 −2
  11. crates/ui2/src/components/label.rs +1 −1
crates/editor2/src/editor.rs+2−0
@@ -9423,6 +9423,7 @@ impl Render for Editor { · 9422 unmodified lines
94239423 font_weight: FontWeight::NORMAL,
94249424 font_style: FontStyle::Normal,
94259425 line_height: relative(1.).into(),
9426+ background_color: None,
94269427 underline: None,
94279428 white_space: WhiteSpace::Normal,
94289429 },
@@ -9437,6 +9438,7 @@ impl Render for Editor { · 8 unmodified lines
94379438 font_weight: FontWeight::NORMAL,
94389439 font_style: FontStyle::Normal,
94399440 line_height: relative(settings.buffer_line_height.value()),
9441+ background_color: None,
94409442 underline: None,
94419443 white_space: WhiteSpace::Normal,
94429444 },
crates/editor2/src/element.rs+1−1
@@ -2452,7 +2452,7 @@ impl LineWithInvisibles { · 2451 unmodified lines
24522452 len: line_chunk.len(),
24532453 font: text_style.font(),
24542454 color: text_style.color,
2455− background_color: None,
2455+ background_color: text_style.background_color,
24562456 underline: text_style.underline,
24572457 });
24582458
crates/gpui2/src/elements/text.rs+116−19
@@ -1,11 +1,11 @@
11 use crate::{
2− Bounds, Element, ElementId, IntoElement, LayoutId, Pixels, SharedString, Size, TextRun,
3− WhiteSpace, WindowContext, WrappedLine,
2+ Bounds, DispatchPhase, Element, ElementId, IntoElement, LayoutId, MouseDownEvent, MouseUpEvent,
3+ Pixels, Point, SharedString, Size, TextRun, WhiteSpace, WindowContext, WrappedLine,
44 };
55 use anyhow::anyhow;
66 use parking_lot::{Mutex, MutexGuard};
77 use smallvec::SmallVec;
8−use std::{cell::Cell, rc::Rc, sync::Arc};
8+use std::{cell::Cell, ops::Range, rc::Rc, sync::Arc};
99 use util::ResultExt;
1010
1111 impl Element for &'static str {
@@ -69,23 +69,28 @@ impl IntoElement for SharedString { · 57 unmodified lines
6969 }
7070 }
7171
72+/// Renders text with runs of different styles.
73+///
74+/// Callers are responsible for setting the correct style for each run.
75+/// For text with a uniform style, you can usually avoid calling this constructor
76+/// and just pass text directly.
7277 pub struct StyledText {
7378 text: SharedString,
7479 runs: Option<Vec<TextRun>>,
7580 }
7681
7782 impl StyledText {
78− /// Renders text with runs of different styles.
79− ///
80− /// Callers are responsible for setting the correct style for each run.
81− /// For text with a uniform style, you can usually avoid calling this constructor
82− /// and just pass text directly.
83− pub fn new(text: SharedString, runs: Vec<TextRun>) -> Self {
83+ pub fn new(text: impl Into<SharedString>) -> Self {
8484 StyledText {
85− text,
86− runs: Some(runs),
85+ text: text.into(),
86+ runs: None,
8787 }
8888 }
89+
90+ pub fn with_runs(mut self, runs: Vec<TextRun>) -> Self {
91+ self.runs = Some(runs);
92+ self
93+ }
8994 }
9095
9196 impl Element for StyledText {
@@ -226,16 +231,73 @@ impl TextState { · 134 unmodified lines
226231 line_origin.y += line.size(line_height).height;
227232 }
228233 }
234+
235+ fn index_for_position(&self, bounds: Bounds<Pixels>, position: Point<Pixels>) -> Option<usize> {
236+ if !bounds.contains_point(&position) {
237+ return None;
238+ }
239+
240+ let element_state = self.lock();
241+ let element_state = element_state
242+ .as_ref()
243+ .expect("measurement has not been performed");
244+
245+ let line_height = element_state.line_height;
246+ let mut line_origin = bounds.origin;
247+ for line in &element_state.lines {
248+ let line_bottom = line_origin.y + line.size(line_height).height;
249+ if position.y > line_bottom {
250+ line_origin.y = line_bottom;
251+ } else {
252+ let position_within_line = position - line_origin;
253+ return line.index_for_position(position_within_line, line_height);
254+ }
255+ }
256+
257+ None
258+ }
229259 }
230260
231−struct InteractiveText {
261+pub struct InteractiveText {
232262 element_id: ElementId,
233263 text: StyledText,
264+ click_listener: Option<Box<dyn Fn(InteractiveTextClickEvent, &mut WindowContext<'_>)>>,
234265 }
235266
236−struct InteractiveTextState {
267+struct InteractiveTextClickEvent {
268+ mouse_down_index: usize,
269+ mouse_up_index: usize,
270+}
271+
272+pub struct InteractiveTextState {
237273 text_state: TextState,
238− clicked_range_ixs: Rc<Cell<SmallVec<[usize; 1]>>>,
274+ mouse_down_index: Rc<Cell<Option<usize>>>,
275+}
276+
277+impl InteractiveText {
278+ pub fn new(id: impl Into<ElementId>, text: StyledText) -> Self {
279+ Self {
280+ element_id: id.into(),
281+ text,
282+ click_listener: None,
283+ }
284+ }
285+
286+ pub fn on_click(
287+ mut self,
288+ ranges: Vec<Range<usize>>,
289+ listener: impl Fn(usize, &mut WindowContext<'_>) + 'static,
290+ ) -> Self {
291+ self.click_listener = Some(Box::new(move |event, cx| {
292+ for (range_ix, range) in ranges.iter().enumerate() {
293+ if range.contains(&event.mouse_down_index) && range.contains(&event.mouse_up_index)
294+ {
295+ listener(range_ix, cx);
296+ }
297+ }
298+ }));
299+ self
300+ }
239301 }
240302
241303 impl Element for InteractiveText {
@@ -247,27 +309,62 @@ impl Element for InteractiveText { · 5 unmodified lines
247309 cx: &mut WindowContext,
248310 ) -> (LayoutId, Self::State) {
249311 if let Some(InteractiveTextState {
250− text_state,
251− clicked_range_ixs,
312+ mouse_down_index, ..
252313 }) = state
253314 {
254− let (layout_id, text_state) = self.text.layout(Some(text_state), cx);
315+ let (layout_id, text_state) = self.text.layout(None, cx);
255316 let element_state = InteractiveTextState {
256317 text_state,
257− clicked_range_ixs,
318+ mouse_down_index,
258319 };
259320 (layout_id, element_state)
260321 } else {
261322 let (layout_id, text_state) = self.text.layout(None, cx);
262323 let element_state = InteractiveTextState {
263324 text_state,
264− clicked_range_ixs: Rc::default(),
325+ mouse_down_index: Rc::default(),
265326 };
266327 (layout_id, element_state)
267328 }
268329 }
269330
270331 fn paint(self, bounds: Bounds<Pixels>, state: &mut Self::State, cx: &mut WindowContext) {
332+ if let Some(click_listener) = self.click_listener {
333+ let text_state = state.text_state.clone();
334+ let mouse_down = state.mouse_down_index.clone();
335+ if let Some(mouse_down_index) = mouse_down.get() {
336+ cx.on_mouse_event(move |event: &MouseUpEvent, phase, cx| {
337+ if phase == DispatchPhase::Bubble {
338+ if let Some(mouse_up_index) =
339+ text_state.index_for_position(bounds, event.position)
340+ {
341+ click_listener(
342+ InteractiveTextClickEvent {
343+ mouse_down_index,
344+ mouse_up_index,
345+ },
346+ cx,
347+ )
348+ }
349+
350+ mouse_down.take();
351+ cx.notify();
352+ }
353+ });
354+ } else {
355+ cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| {
356+ if phase == DispatchPhase::Bubble {
357+ if let Some(mouse_down_index) =
358+ text_state.index_for_position(bounds, event.position)
359+ {
360+ mouse_down.set(Some(mouse_down_index));
361+ cx.notify();
362+ }
363+ }
364+ });
365+ }
366+ }
367+
271368 self.text.paint(bounds, &mut state.text_state, cx)
272369 }
273370 }
crates/gpui2/src/style.rs+13−1
@@ -145,6 +145,7 @@ pub struct TextStyle { · 144 unmodified lines
145145 pub line_height: DefiniteLength,
146146 pub font_weight: FontWeight,
147147 pub font_style: FontStyle,
148+ pub background_color: Option<Hsla>,
148149 pub underline: Option<UnderlineStyle>,
149150 pub white_space: WhiteSpace,
150151 }
@@ -159,6 +160,7 @@ impl Default for TextStyle { · 8 unmodified lines
159160 line_height: phi(),
160161 font_weight: FontWeight::default(),
161162 font_style: FontStyle::default(),
163+ background_color: None,
162164 underline: None,
163165 white_space: WhiteSpace::Normal,
164166 }
@@ -182,6 +184,10 @@ impl TextStyle { · 17 unmodified lines
182184 self.color.fade_out(factor);
183185 }
184186
187+ if let Some(background_color) = style.background_color {
188+ self.background_color = Some(background_color);
189+ }
190+
185191 if let Some(underline) = style.underline {
186192 self.underline = Some(underline);
187193 }
@@ -212,7 +218,7 @@ impl TextStyle { · 24 unmodified lines
212218 style: self.font_style,
213219 },
214220 color: self.color,
215− background_color: None,
221+ background_color: self.background_color,
216222 underline: self.underline.clone(),
217223 }
218224 }
@@ -223,6 +229,7 @@ pub struct HighlightStyle { · 4 unmodified lines
223229 pub color: Option<Hsla>,
224230 pub font_weight: Option<FontWeight>,
225231 pub font_style: Option<FontStyle>,
232+ pub background_color: Option<Hsla>,
226233 pub underline: Option<UnderlineStyle>,
227234 pub fade_out: Option<f32>,
228235 }
@@ -441,6 +448,7 @@ impl From<&TextStyle> for HighlightStyle { · 212 unmodified lines
441448 color: Some(other.color),
442449 font_weight: Some(other.font_weight),
443450 font_style: Some(other.font_style),
451+ background_color: other.background_color,
444452 underline: other.underline.clone(),
445453 fade_out: None,
446454 }
@@ -467,6 +475,10 @@ impl HighlightStyle { · 20 unmodified lines
467475 self.font_style = other.font_style;
468476 }
469477
478+ if other.background_color.is_some() {
479+ self.background_color = other.background_color;
480+ }
481+
470482 if other.underline.is_some() {
471483 self.underline = other.underline;
472484 }
crates/gpui2/src/styled.rs+7−0
@@ -361,6 +361,13 @@ pub trait Styled: Sized { · 360 unmodified lines
361361 self
362362 }
363363
364+ fn text_bg(mut self, bg: impl Into<Hsla>) -> Self {
365+ self.text_style()
366+ .get_or_insert_with(Default::default)
367+ .background_color = Some(bg.into());
368+ self
369+ }
370+
364371 fn text_size(mut self, size: impl Into<AbsoluteLength>) -> Self {
365372 self.text_style()
366373 .get_or_insert_with(Default::default)
crates/gpui2/src/text_system.rs+9−2
@@ -196,7 +196,10 @@ impl TextSystem { · 195 unmodified lines
196196 let mut decoration_runs = SmallVec::<[DecorationRun; 32]>::new();
197197 for run in runs {
198198 if let Some(last_run) = decoration_runs.last_mut() {
199− if last_run.color == run.color && last_run.underline == run.underline {
199+ if last_run.color == run.color
200+ && last_run.underline == run.underline
201+ && last_run.background_color == run.background_color
202+ {
200203 last_run.len += run.len as u32;
201204 continue;
202205 }
@@ -204,6 +207,7 @@ impl TextSystem { · 1 unmodified line
204207 decoration_runs.push(DecorationRun {
205208 len: run.len as u32,
206209 color: run.color,
210+ background_color: run.background_color,
207211 underline: run.underline.clone(),
208212 });
209213 }
@@ -254,13 +258,16 @@ impl TextSystem { · 44 unmodified lines
254258 }
255259
256260 if decoration_runs.last().map_or(false, |last_run| {
257− last_run.color == run.color && last_run.underline == run.underline
261+ last_run.color == run.color
262+ && last_run.underline == run.underline
263+ && last_run.background_color == run.background_color
258264 }) {
259265 decoration_runs.last_mut().unwrap().len += run_len_within_line as u32;
260266 } else {
261267 decoration_runs.push(DecorationRun {
262268 len: run_len_within_line as u32,
263269 color: run.color,
270+ background_color: run.background_color,
264271 underline: run.underline.clone(),
265272 });
266273 }
crates/gpui2/src/text_system/line.rs+59−5
@@ -1,6 +1,7 @@
11 use crate::{
2− black, point, px, BorrowWindow, Bounds, Hsla, LineLayout, Pixels, Point, Result, SharedString,
3− UnderlineStyle, WindowContext, WrapBoundary, WrappedLineLayout,
2+ black, point, px, size, transparent_black, BorrowWindow, Bounds, Corners, Edges, Hsla,
3+ LineLayout, Pixels, Point, Result, SharedString, UnderlineStyle, WindowContext, WrapBoundary,
4+ WrappedLineLayout,
45 };
56 use derive_more::{Deref, DerefMut};
67 use smallvec::SmallVec;
@@ -10,6 +11,7 @@ use std::sync::Arc; · 3 unmodified lines
1011 pub struct DecorationRun {
1112 pub len: u32,
1213 pub color: Hsla,
14+ pub background_color: Option<Hsla>,
1315 pub underline: Option<UnderlineStyle>,
1416 }
1517
@@ -97,6 +99,7 @@ fn paint_line( · 81 unmodified lines
9799 let mut run_end = 0;
98100 let mut color = black();
99101 let mut current_underline: Option<(Point<Pixels>, UnderlineStyle)> = None;
102+ let mut current_background: Option<(Point<Pixels>, Hsla)> = None;
100103 let text_system = cx.text_system().clone();
101104 let mut glyph_origin = origin;
102105 let mut prev_glyph_position = Point::default();
@@ -110,12 +113,24 @@ fn paint_line( · 7 unmodified lines
110113
111114 if wraps.peek() == Some(&&WrapBoundary { run_ix, glyph_ix }) {
112115 wraps.next();
116+ if let Some((background_origin, background_color)) = current_background.take() {
117+ cx.paint_quad(
118+ Bounds {
119+ origin: background_origin,
120+ size: size(glyph_origin.x - background_origin.x, line_height),
121+ },
122+ Corners::default(),
123+ background_color,
124+ Edges::default(),
125+ transparent_black(),
126+ );
127+ }
113128 if let Some((underline_origin, underline_style)) = current_underline.take() {
114129 cx.paint_underline(
115130 underline_origin,
116131 glyph_origin.x - underline_origin.x,
117132 &underline_style,
118− )?;
133+ );
119134 }
120135
121136 glyph_origin.x = origin.x;
@@ -123,9 +138,20 @@ fn paint_line( · 1 unmodified line
123138 }
124139 prev_glyph_position = glyph.position;
125140
141+ let mut finished_background: Option<(Point<Pixels>, Hsla)> = None;
126142 let mut finished_underline: Option<(Point<Pixels>, UnderlineStyle)> = None;
127143 if glyph.index >= run_end {
128144 if let Some(style_run) = decoration_runs.next() {
145+ if let Some((_, background_color)) = &mut current_background {
146+ if style_run.background_color.as_ref() != Some(background_color) {
147+ finished_background = current_background.take();
148+ }
149+ }
150+ if let Some(run_background) = style_run.background_color {
151+ current_background
152+ .get_or_insert((point(glyph_origin.x, origin.y), run_background));
153+ }
154+
129155 if let Some((_, underline_style)) = &mut current_underline {
130156 if style_run.underline.as_ref() != Some(underline_style) {
131157 finished_underline = current_underline.take();
@@ -149,16 +175,30 @@ fn paint_line( · 17 unmodified lines
149175 color = style_run.color;
150176 } else {
151177 run_end = layout.len;
178+ finished_background = current_background.take();
152179 finished_underline = current_underline.take();
153180 }
154181 }
155182
183+ if let Some((background_origin, background_color)) = finished_background {
184+ cx.paint_quad(
185+ Bounds {
186+ origin: background_origin,
187+ size: size(glyph_origin.x - background_origin.x, line_height),
188+ },
189+ Corners::default(),
190+ background_color,
191+ Edges::default(),
192+ transparent_black(),
193+ );
194+ }
195+
156196 if let Some((underline_origin, underline_style)) = finished_underline {
157197 cx.paint_underline(
158198 underline_origin,
159199 glyph_origin.x - underline_origin.x,
160200 &underline_style,
161− )?;
201+ );
162202 }
163203
164204 let max_glyph_bounds = Bounds {
@@ -188,13 +228,27 @@ fn paint_line( · 23 unmodified lines
188228 }
189229 }
190230
231+ if let Some((background_origin, background_color)) = current_background.take() {
232+ let line_end_x = origin.x + wrap_width.unwrap_or(Pixels::MAX).min(layout.width);
233+ cx.paint_quad(
234+ Bounds {
235+ origin: background_origin,
236+ size: size(line_end_x - background_origin.x, line_height),
237+ },
238+ Corners::default(),
239+ background_color,
240+ Edges::default(),
241+ transparent_black(),
242+ );
243+ }
244+
191245 if let Some((underline_start, underline_style)) = current_underline.take() {
192246 let line_end_x = origin.x + wrap_width.unwrap_or(Pixels::MAX).min(layout.width);
193247 cx.paint_underline(
194248 underline_start,
195249 line_end_x - underline_start.x,
196250 &underline_style,
197− )?;
251+ );
198252 }
199253
200254 Ok(())
crates/gpui2/src/text_system/line_layout.rs+35−0
@@ -198,6 +198,41 @@ impl WrappedLineLayout { · 197 unmodified lines
198198 pub fn runs(&self) -> &[ShapedRun] {
199199 &self.unwrapped_layout.runs
200200 }
201+
202+ pub fn index_for_position(
203+ &self,
204+ position: Point<Pixels>,
205+ line_height: Pixels,
206+ ) -> Option<usize> {
207+ let wrapped_line_ix = (position.y / line_height) as usize;
208+
209+ let wrapped_line_start_x = if wrapped_line_ix > 0 {
210+ let wrap_boundary_ix = wrapped_line_ix - 1;
211+ let wrap_boundary = self.wrap_boundaries[wrap_boundary_ix];
212+ let run = &self.unwrapped_layout.runs[wrap_boundary.run_ix];
213+ run.glyphs[wrap_boundary.glyph_ix].position.x
214+ } else {
215+ Pixels::ZERO
216+ };
217+
218+ let wrapped_line_end_x = if wrapped_line_ix < self.wrap_boundaries.len() {
219+ let next_wrap_boundary_ix = wrapped_line_ix;
220+ let next_wrap_boundary = self.wrap_boundaries[next_wrap_boundary_ix];
221+ let run = &self.unwrapped_layout.runs[next_wrap_boundary.run_ix];
222+ run.glyphs[next_wrap_boundary.glyph_ix].position.x
223+ } else {
224+ self.unwrapped_layout.width
225+ };
226+
227+ let mut position_in_unwrapped_line = position;
228+ position_in_unwrapped_line.x += wrapped_line_start_x;
229+ if position_in_unwrapped_line.x > wrapped_line_end_x {
230+ None
231+ } else {
232+ self.unwrapped_layout
233+ .index_for_x(position_in_unwrapped_line.x)
234+ }
235+ }
201236 }
202237
203238 pub(crate) struct LineLayoutCache {
crates/gpui2/src/window.rs+1−2
@@ -881,7 +881,7 @@ impl<'a> WindowContext<'a> { · 880 unmodified lines
881881 origin: Point<Pixels>,
882882 width: Pixels,
883883 style: &UnderlineStyle,
884− ) -> Result<()> {
884+ ) {
885885 let scale_factor = self.scale_factor();
886886 let height = if style.wavy {
887887 style.thickness * 3.
@@ -905,7 +905,6 @@ impl<'a> WindowContext<'a> { · 17 unmodified lines
905905 wavy: style.wavy,
906906 },
907907 );
908− Ok(())
909908 }
910909
911910 /// Paint a monochrome (non-emoji) glyph into the scene for the current frame at the current z-index.
crates/storybook2/src/stories/text.rs+18−2
@@ -1,5 +1,6 @@
11 use gpui::{
2− blue, div, red, white, Div, ParentElement, Render, Styled, View, VisualContext, WindowContext,
2+ blue, div, green, red, white, Div, InteractiveText, ParentElement, Render, Styled, StyledText,
3+ TextRun, View, VisualContext, WindowContext,
34 };
45 use ui::v_stack;
56
@@ -55,6 +56,21 @@ impl Render for TextStory { · 49 unmodified lines
5556 "flex-row. width 96. The quick brown fox jumps over the lazy dog. ",
5657 "Meanwhile, the lazy dog decided it was time for a change. ",
5758 "He started daily workout routines, ate healthier and became the fastest dog in town.",
58− )))
59+ ))).child(
60+ InteractiveText::new(
61+ "interactive",
62+ StyledText::new("Hello world, how is it going?").with_runs(vec![
63+ cx.text_style().to_run(6),
64+ TextRun {
65+ background_color: Some(green()),
66+ ..cx.text_style().to_run(5)
67+ },
68+ cx.text_style().to_run(18),
69+ ]),
70+ )
71+ .on_click(vec![2..4, 1..3, 7..9], |range_ix, cx| {
72+ println!("Clicked range {range_ix}");
73+ })
74+ )
5975 }
6076 }
crates/ui2/src/components/label.rs+1−1
@@ -150,7 +150,7 @@ impl RenderOnce for HighlightedLabel { · 149 unmodified lines
150150 LabelSize::Default => this.text_ui(),
151151 LabelSize::Small => this.text_ui_sm(),
152152 })
153− .child(StyledText::new(self.label, runs))
153+ .child(StyledText::new(self.label).with_runs(runs))
154154 }
155155 }
156156

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t