use std::mem;
use std::ops::Range;
use std::rc::Rc;
use gpui::{
accesskit, App, Bounds, CursorStyle, DispatchPhase, Element, ElementId, GlobalElementId,
Hitbox, HitboxBehavior, IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
Pixels, Role, SharedString, StyledText, TextLayout, Window,
};
use super::selection::{word_range_at, MarkdownSelection, SelectionPosition};
pub(crate) struct RegisteredRun {
pub layout: TextLayout,
pub text: SharedString,
}
impl RegisteredRun {
#[cfg(test)]
pub(crate) fn for_test(text: &str) -> Self {
Self {
layout: TextLayout::default(),
text: SharedString::from(text.to_string()),
}
}
}
type ClickListener = Rc<dyn Fn(usize, &mut Window, &mut App)>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RunRole {
Paragraph,
Heading(u8),
Quote,
ListItem,
Code,
}
impl RunRole {
pub fn a11y_role(self) -> Role {
match self {
RunRole::Paragraph => Role::Paragraph,
RunRole::Heading(_) => Role::Heading,
RunRole::Quote => Role::Blockquote,
RunRole::ListItem => Role::ListItem,
RunRole::Code => Role::Code,
}
}
}
pub struct SelectableText {
element_id: ElementId,
text: StyledText,
plain_text: SharedString,
role: RunRole,
run: usize,
selection: MarkdownSelection,
clickable_ranges: Vec<Range<usize>>,
click_listener: Option<ClickListener>,
}
impl SelectableText {
pub fn new(
id: impl Into<ElementId>,
text: StyledText,
plain_text: impl Into<SharedString>,
role: RunRole,
run: usize,
selection: MarkdownSelection,
) -> Self {
Self {
element_id: id.into(),
text,
plain_text: plain_text.into(),
role,
run,
selection,
clickable_ranges: Vec::new(),
click_listener: None,
}
}
pub fn on_click(
mut self,
ranges: Vec<Range<usize>>,
listener: impl Fn(usize, &mut Window, &mut App) + 'static,
) -> Self {
self.clickable_ranges = ranges;
self.click_listener = Some(Rc::new(listener));
self
}
}
impl IntoElement for SelectableText {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for SelectableText {
type RequestLayoutState = ();
type PrepaintState = Hitbox;
fn id(&self) -> Option<ElementId> {
Some(self.element_id.clone())
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn a11y_role(&self) -> Option<Role> {
Some(self.role.a11y_role())
}
fn write_a11y_info(&self, node: &mut accesskit::Node) {
node.set_label(self.plain_text.to_string());
if let RunRole::Heading(level) = self.role {
node.set_level(level as usize);
}
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
inspector_id: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
self.text.request_layout(None, inspector_id, window, cx)
}
fn prepaint(
&mut self,
_global_id: Option<&GlobalElementId>,
inspector_id: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
state: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Hitbox {
#[cfg(test)]
recorder::record(self, _global_id);
self.text
.prepaint(None, inspector_id, bounds, state, window, cx);
window.insert_hitbox(bounds, HitboxBehavior::Normal)
}
fn paint(
&mut self,
_global_id: Option<&GlobalElementId>,
inspector_id: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
hitbox: &mut Hitbox,
window: &mut Window,
cx: &mut App,
) {
let text_layout = self.text.layout().clone();
let selection = self.selection.clone();
let run = self.run;
selection.register_run(
run,
RegisteredRun {
layout: text_layout.clone(),
text: SharedString::from(text_layout.text()),
},
);
let over_link = text_layout
.index_for_position(window.mouse_position())
.is_ok_and(|ix| {
self.clickable_ranges
.iter()
.any(|range| range.contains(&ix))
});
window.set_cursor_style(
if over_link {
CursorStyle::PointingHand
} else {
CursorStyle::IBeam
},
hitbox,
);
{
let selection = selection.clone();
let text_layout = text_layout.clone();
let hitbox = hitbox.clone();
window.on_mouse_event(move |event: &MouseDownEvent, phase, window, _cx| {
if phase != DispatchPhase::Bubble {
return;
}
if hitbox.is_hovered(window) {
let offset = match text_layout.index_for_position(event.position) {
Ok(offset) => offset,
Err(nearest) => nearest,
};
match event.click_count {
1 => selection.begin_drag(SelectionPosition { run, offset }),
2 => {
if let Some(text) = selection.run_text(run) {
selection.select_in_run(run, word_range_at(&text, offset));
}
}
_ => {
let len = selection.run_text(run).map_or(0, |text| text.len());
selection.select_in_run(run, 0..len);
}
}
window.refresh();
} else if run == 0
&& !selection.is_empty()
&& !selection.point_in_any_run(event.position)
{
selection.clear();
window.refresh();
}
});
}
if selection.drag_anchor_run() == Some(run) {
{
let selection = selection.clone();
window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, _cx| {
if phase != DispatchPhase::Bubble || !selection.is_dragging() {
return;
}
if let Some(position) = selection.position_for_point(event.position) {
selection.update_head(position);
window.refresh();
}
});
}
{
let selection = selection.clone();
let text_layout = text_layout.clone();
let hitbox = hitbox.clone();
let clickable_ranges = mem::take(&mut self.clickable_ranges);
let click_listener = self.click_listener.clone();
window.on_mouse_event(move |event: &MouseUpEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble || !selection.is_dragging() {
return;
}
let was_click = selection.range().is_none();
selection.end_drag();
if was_click && hitbox.is_hovered(window) {
if let (Some(listener), Ok(ix)) = (
click_listener.as_ref(),
text_layout.index_for_position(event.position),
) {
if let Some(range_ix) = clickable_ranges
.iter()
.position(|range| range.contains(&ix))
{
listener(range_ix, window, cx);
}
}
}
window.refresh();
});
}
}
self.text
.paint(None, inspector_id, bounds, &mut (), &mut (), window, cx);
}
}
#[cfg(test)]
pub(crate) mod recorder {
use super::*;
use std::cell::RefCell;
#[derive(Clone, Debug)]
pub(crate) struct RecordedRun {
pub id_path: String,
pub id_segments: Vec<String>,
pub role: Option<Role>,
pub label: Option<String>,
pub level: Option<usize>,
}
thread_local! {
static RECORDED: RefCell<Vec<RecordedRun>> = const { RefCell::new(Vec::new()) };
}
pub(crate) fn record(text: &SelectableText, global_id: Option<&GlobalElementId>) {
let Some(global_id) = global_id else {
return;
};
let (role, label, level) = match text.a11y_role() {
Some(role) => {
let mut node = accesskit::Node::new(role);
text.write_a11y_info(&mut node);
(Some(role), node.label().map(str::to_owned), node.level())
}
None => (None, None, None),
};
RECORDED.with(|recorded| {
recorded.borrow_mut().push(RecordedRun {
id_path: global_id.to_string(),
id_segments: global_id.iter().map(|id| id.to_string()).collect(),
role,
label,
level,
})
});
}
pub(crate) fn clear() {
RECORDED.with(|recorded| recorded.borrow_mut().clear());
}
pub(crate) fn take() -> Vec<RecordedRun> {
RECORDED.with(|recorded| std::mem::take(&mut *recorded.borrow_mut()))
}
}