diff --git a/crates/gpui2/src/elements/text.rs b/crates/gpui2/src/elements/text.rs index 74b74d0d05a22fa98de6262da6edaad3aa01e855..c81d4ff0edafc32271e3ececc4873aead286274c 100644 --- a/crates/gpui2/src/elements/text.rs +++ b/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>, } 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) -> Self { + pub fn new(text: impl Into) -> Self { StyledText { - text, - runs: Some(runs), + text: text.into(), + runs: None, } } + + pub fn with_runs(mut self, runs: Vec) -> 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, position: Point) -> Option { + 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)>>, } -struct InteractiveTextState { +struct InteractiveTextClickEvent { + mouse_down_index: usize, + mouse_up_index: usize, +} + +pub struct InteractiveTextState { text_state: TextState, - clicked_range_ixs: Rc>>, + mouse_down_index: Rc>>, +} + +impl InteractiveText { + pub fn new(id: impl Into, text: StyledText) -> Self { + Self { + element_id: id.into(), + text, + click_listener: None, + } + } + + pub fn on_click( + mut self, + ranges: Vec>, + 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, 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) } } diff --git a/crates/gpui2/src/text_system/line_layout.rs b/crates/gpui2/src/text_system/line_layout.rs index a5cf814a8c24e36d5c41217f7db5aa528452d312..2370aca83b0c04874c71d738b7e26a21ad272ccd 100644 --- a/crates/gpui2/src/text_system/line_layout.rs +++ b/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, + line_height: Pixels, + ) -> Option { + 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 { diff --git a/crates/storybook2/src/stories/text.rs b/crates/storybook2/src/stories/text.rs index c26e5fd3f1adc78aa3e9e8bf99f8c8f26666db78..4c478c929892e7a3fb0fab8026274ecaa225a423 100644 --- a/crates/storybook2/src/stories/text.rs +++ b/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);})) } } diff --git a/crates/ui2/src/components/label.rs b/crates/ui2/src/components/label.rs index eafb6d55bb815f66544909f19347fce90e98596b..627a95d9534d2a4aadda019d6be59b2977653ed3 100644 --- a/crates/ui2/src/components/label.rs +++ b/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)) } }