use gpui::Corners;
use std::{
cell::RefCell,
collections::HashMap,
mem,
ops::Range,
rc::Rc,
sync::{Arc, Mutex, Weak},
};
use gpui::{
App, BorderStyle, Bounds, ClickEvent, CursorStyle, Edges, Element, ElementId, GlobalElementId,
Half, HighlightStyle, Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId,
MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point,
SharedString, StyledText, TextLayout, TextRun, TextStyle, Window, point, px, quad, size,
};
use crate::{
GlobalState, TextSelection,
input::Selection,
text::TextViewMultiClickKind,
text::node::LinkMark,
text::selection::word_range_at,
text::state::LineSpan,
text::text_view::{LinkClickHandlerFn, handle_link_click},
};
#[derive(Clone, Debug, Default, PartialEq)]
pub(super) struct InlineHighlight {
pub(super) style: HighlightStyle,
pub(super) font_family: Option<SharedString>,
pub(super) font_size_scale: Option<f32>,
}
impl InlineHighlight {
fn highlight(mut self, other: &InlineHighlight) -> Self {
self.style = self.style.highlight(other.style);
if other.font_family.is_some() {
self.font_family = other.font_family.clone();
}
if other.font_size_scale.is_some() {
self.font_size_scale = other.font_size_scale;
}
self
}
}
impl From<HighlightStyle> for InlineHighlight {
fn from(style: HighlightStyle) -> Self {
Self {
style,
font_family: None,
font_size_scale: None,
}
}
}
pub(super) fn combine_highlights(
a: impl IntoIterator<Item = (Range<usize>, InlineHighlight)>,
b: impl IntoIterator<Item = (Range<usize>, InlineHighlight)>,
) -> Vec<(Range<usize>, InlineHighlight)> {
let mut endpoints = Vec::new();
let mut highlights = Vec::new();
for (range, highlight) in a.into_iter().chain(b) {
if !range.is_empty() {
let id = highlights.len();
endpoints.push((range.start, id, true));
endpoints.push((range.end, id, false));
highlights.push(highlight);
}
}
endpoints.sort_unstable_by_key(|(position, _, _)| *position);
let mut combined = Vec::new();
let mut active: Vec<usize> = Vec::new();
let mut ix = 0;
for (position, id, is_start) in endpoints {
if position > ix && !active.is_empty() {
let style = active.iter().fold(InlineHighlight::default(), |acc, id| {
acc.highlight(&highlights[*id])
});
combined.push((ix..position, style));
}
ix = position;
if is_start {
active.push(id);
} else {
active.retain(|active_id| *active_id != id);
}
}
combined
}
pub(super) fn fade_highlights(
highlights: Vec<(Range<usize>, InlineHighlight)>,
fades: &[(Range<usize>, f32)],
) -> Vec<(Range<usize>, InlineHighlight)> {
if fades.is_empty() {
return highlights;
}
let fade_highlights = fades.iter().map(|(range, fade_out)| {
(
range.clone(),
InlineHighlight::from(HighlightStyle {
fade_out: Some(*fade_out),
..Default::default()
}),
)
});
let mut combined = combine_highlights(highlights, fade_highlights);
for (_, highlight) in &mut combined {
if let Some(fade_out) = highlight.style.fade_out
&& let Some(background) = highlight.style.background_color.as_mut()
{
background.fade_out(fade_out);
}
}
combined
}
pub(super) fn text_runs(
text_len: usize,
default_style: &TextStyle,
highlights: &[(Range<usize>, InlineHighlight)],
) -> Vec<TextRun> {
let mut runs = Vec::with_capacity(highlights.len() * 2 + 1);
let mut ix = 0;
for (range, highlight) in highlights {
if ix < range.start {
runs.push(default_style.clone().to_run(range.start - ix));
}
let mut run = default_style
.clone()
.highlight(highlight.style)
.to_run(range.len());
if let Some(family) = &highlight.font_family {
run.font.family = family.clone();
}
runs.push(run);
ix = range.end;
}
if ix < text_len {
runs.push(default_style.to_run(text_len - ix));
}
runs
}
pub(super) fn text_size_ranges(
text_len: usize,
highlights: &[(Range<usize>, InlineHighlight)],
) -> Vec<(Range<usize>, f32)> {
let mut ranges: Vec<(Range<usize>, f32)> = Vec::new();
let mut push = |range: Range<usize>, scale: f32| {
if range.is_empty() {
return;
}
if let Some((last, last_scale)) = ranges.last_mut()
&& *last_scale == scale
&& last.end == range.start
{
last.end = range.end;
} else {
ranges.push((range, scale));
}
};
let mut cursor = 0;
for (range, highlight) in highlights {
push(cursor..range.start, 1.);
push(range.clone(), highlight.font_size_scale.unwrap_or(1.));
cursor = range.end;
}
push(cursor..text_len, 1.);
ranges
}
pub(super) struct Inline {
text: SharedString,
links: Rc<Vec<(Range<usize>, LinkMark)>>,
highlights: Vec<(Range<usize>, InlineHighlight)>,
styled_text: StyledText,
text_style: Option<TextStyle>,
paint_origin: Option<Point<Pixels>>,
selection_bounds: Option<Bounds<Pixels>>,
selection_source: Option<(Arc<Mutex<InlineState>>, Range<usize>)>,
link_click_handler: Option<Arc<LinkClickHandlerFn>>,
retained_key: Option<(Vec<TextRun>, TextStyle)>,
handed_over: bool,
state: Arc<Mutex<InlineState>>,
}
#[derive(Debug, Default, PartialEq)]
pub(crate) struct InlineState {
hovered_index: Option<usize>,
pub(super) text: SharedString,
pub(super) selection: Option<Selection>,
}
struct RetainedLayout {
state: Weak<Mutex<InlineState>>,
styled_text: StyledText,
text: SharedString,
runs: Vec<TextRun>,
text_style: TextStyle,
}
thread_local! {
static RETAINED_LAYOUTS: RefCell<HashMap<usize, RetainedLayout>> = RefCell::new(HashMap::new());
}
const RETAINED_SWEEP_AT: usize = 4096;
fn state_key(state: &Arc<Mutex<InlineState>>) -> usize {
Arc::as_ptr(state) as usize
}
fn take_retained_layout(state: &Arc<Mutex<InlineState>>) -> Option<RetainedLayout> {
RETAINED_LAYOUTS.with(|layouts| {
let retained = layouts.borrow_mut().remove(&state_key(state))?;
retained
.state
.upgrade()
.is_some_and(|live| Arc::ptr_eq(&live, state))
.then_some(retained)
})
}
fn has_retained_layout(state: &Arc<Mutex<InlineState>>) -> bool {
RETAINED_LAYOUTS.with(|layouts| layouts.borrow().contains_key(&state_key(state)))
}
fn retain_layout(state: &Arc<Mutex<InlineState>>, retained: RetainedLayout) {
RETAINED_LAYOUTS.with(|layouts| {
let mut layouts = layouts.borrow_mut();
if layouts.len() >= RETAINED_SWEEP_AT {
layouts.retain(|_, retained| retained.state.strong_count() > 0);
}
layouts.insert(state_key(state), retained);
});
}
impl InlineState {
pub(crate) fn set_text(&mut self, text: SharedString) {
self.text = text;
}
}
impl Inline {
fn retain_styled_text(&mut self) {
if self.handed_over || self.retained_key.is_none() || has_retained_layout(&self.state) {
return;
}
let Some((runs, text_style)) = self.retained_key.take() else {
return;
};
retain_layout(
&self.state,
RetainedLayout {
state: Arc::downgrade(&self.state),
styled_text: mem::replace(&mut self.styled_text, StyledText::new("")),
text: self.text.clone(),
runs,
text_style,
},
);
self.handed_over = true;
}
fn reclaim_styled_text(&mut self) -> bool {
if !self.handed_over {
return true;
}
let Some(retained) = take_retained_layout(&self.state) else {
return false;
};
self.styled_text = retained.styled_text;
self.retained_key = Some((retained.runs, retained.text_style));
self.handed_over = false;
true
}
pub(super) fn new(
state: Arc<Mutex<InlineState>>,
links: Vec<(Range<usize>, LinkMark)>,
highlights: Vec<(Range<usize>, InlineHighlight)>,
link_click_handler: Option<Arc<LinkClickHandlerFn>>,
) -> Self {
let text = state
.lock()
.map(|state| state.text.clone())
.unwrap_or_default();
Self {
links: Rc::new(links),
highlights,
text: text.clone(),
styled_text: StyledText::new(text),
text_style: None,
paint_origin: None,
selection_bounds: None,
selection_source: None,
link_click_handler,
retained_key: None,
handed_over: false,
state,
}
}
pub(super) fn text_style(mut self, text_style: TextStyle) -> Self {
self.text_style = Some(text_style);
self
}
pub(super) fn paint_origin(mut self, origin: Point<Pixels>) -> Self {
self.paint_origin = Some(origin);
self
}
pub(super) fn selection_bounds(mut self, bounds: Bounds<Pixels>) -> Self {
self.selection_bounds = Some(bounds);
self
}
pub(super) fn selection_source(
mut self,
state: Arc<Mutex<InlineState>>,
range: Range<usize>,
) -> Self {
self.selection_source = Some((state, range));
self
}
fn link_for_position(
layout: &TextLayout,
links: &Vec<(Range<usize>, LinkMark)>,
position: Point<Pixels>,
) -> Option<LinkMark> {
let offset = layout.index_for_position(position).ok()?;
for (range, link) in links.iter() {
if range.contains(&offset) {
return Some(link.clone());
}
}
None
}
#[allow(unused)]
fn paint_selected_bounds(&self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
window.paint_quad(gpui::PaintQuad {
bounds,
background: gpui::hsla(0.58, 0.85, 0.62, 0.01).into(),
corner_radii: Corners::default(),
border_color: gpui::transparent_black(),
border_style: BorderStyle::default(),
border_widths: gpui::Edges::all(px(0.)),
});
}
fn layout_selections(
&self,
text_layout: &TextLayout,
bounds: &Bounds<Pixels>,
window: &mut Window,
cx: &mut App,
) -> (bool, bool, Option<Selection>) {
let Some(text_view_state) = GlobalState::global(cx).text_view_state() else {
return (false, false, None);
};
let text_view_state = text_view_state.read(cx);
let is_selectable = text_view_state.is_selectable();
if !is_selectable {
return (false, false, None);
}
if text_view_state.is_all_selected() {
return (is_selectable, true, Some((0..self.text.len()).into()));
}
if text_view_state.preserve_inline_selection {
let selection = if let Some((source, range)) = &self.selection_source {
source
.lock()
.ok()
.and_then(|state| state.selection)
.and_then(|selection| {
let start = selection.start.max(range.start);
let end = selection.end.min(range.end);
(start < end)
.then(|| Selection::new(start - range.start, end - range.start))
})
} else {
self.state.lock().ok().and_then(|state| state.selection)
};
return (true, selection.is_some(), selection);
}
if let Some(selection) = text_view_state.multi_click_selection() {
if selection.kind == TextViewMultiClickKind::Line {
return (
true,
true,
selection
.line_bounds
.filter(|row| row.contains(&bounds.center()))
.map(|_| Selection::new(0, self.text.len())),
);
}
return (
is_selectable,
true,
selection_for_multi_click(
&self.text,
text_layout,
*bounds,
selection.pos,
selection.kind,
)
.map(Selection::from),
);
}
let Some((selection_start, selection_end)) = text_view_state.selection_points(cx) else {
return (is_selectable, false, None);
};
let line_height = window.line_height();
let mut selection: Option<Selection> = None;
let mut offset = 0;
let mut chars = self.text.chars().peekable();
while let Some(c) = chars.next() {
let Some(pos) = text_layout.position_for_index(offset) else {
offset += c.len_utf8();
continue;
};
let next_offset = offset + c.len_utf8();
let mut char_width = line_height.half();
if let Some(next_pos) = text_layout.position_for_index(next_offset) {
if next_pos.y == pos.y {
char_width = next_pos.x - pos.x;
}
}
let selection_pos = self
.selection_bounds
.map_or(pos, |bounds| point(pos.x, bounds.top()));
let selection_height = self
.selection_bounds
.map_or(line_height, |bounds| bounds.size.height);
if point_in_text_selection(
selection_pos,
char_width,
selection_start,
selection_end,
selection_height,
) {
if selection.is_none() {
selection = Some((offset..offset).into());
}
if let Some(selection) = selection.as_mut() {
selection.end = next_offset;
}
}
offset = next_offset;
}
(true, true, selection)
}
fn text_line_bounds(
text_layout: &TextLayout,
line_height: Pixels,
mask_bounds: Bounds<Pixels>,
) -> Vec<Bounds<Pixels>> {
let origin = text_layout.bounds().origin;
let lines = text_layout.line_layouts();
let row_count: usize = lines
.iter()
.map(|line| line.wrap_boundaries.len() + 1)
.sum();
let mut line_bounds = Vec::with_capacity(row_count);
let mut row_ix = 0;
let mut y = origin.y;
for line in &lines {
let layout = &line.unwrapped_layout;
let mut row_start = 0;
let row_ends = line
.wrap_boundaries
.iter()
.map(|boundary| layout.runs[boundary.run_ix].glyphs[boundary.glyph_ix].index)
.chain([line.len()]);
for row_end in row_ends {
let mut width = layout.x_for_index(row_end) - layout.x_for_index(row_start);
row_ix += 1;
if row_ix < row_count {
width += line_height.half();
}
let bounds = Bounds::new(point(origin.x, y), size(width, line_height))
.intersect(&mask_bounds);
if bounds.size.width > px(0.) && bounds.size.height > px(0.) {
line_bounds.push(bounds);
}
y += line_height;
row_start = row_end;
}
}
line_bounds
}
fn selection_edges(
selection: &Selection,
text_layout: &TextLayout,
) -> Option<(Bounds<Pixels>, Bounds<Pixels>)> {
let (start, end) = (
selection.start.min(selection.end),
selection.start.max(selection.end),
);
let line_height = text_layout.line_height();
Some((
crate::touch_selection::caret_line_box(
text_layout.position_for_index(start)?,
line_height,
),
crate::touch_selection::caret_line_box(
text_layout.position_for_index(end)?,
line_height,
),
))
}
fn paint_selection(
selection: &Selection,
text_layout: &TextLayout,
bounds: &Bounds<Pixels>,
window: &mut Window,
color: gpui::Hsla,
) {
let mut start = selection.start;
let mut end = selection.end;
if end < start {
std::mem::swap(&mut start, &mut end);
}
let Some(start_position) = text_layout.position_for_index(start) else {
return;
};
let Some(end_position) = text_layout.position_for_index(end) else {
return;
};
let line_height = text_layout.line_height();
if start_position.y == end_position.y {
window.paint_quad(quad(
Bounds::from_corners(
start_position,
point(end_position.x, end_position.y + line_height),
),
px(0.),
color,
Edges::default(),
gpui::transparent_black(),
BorderStyle::default(),
));
} else {
window.paint_quad(quad(
Bounds::from_corners(
start_position,
point(bounds.right(), start_position.y + line_height),
),
px(0.),
color,
Edges::default(),
gpui::transparent_black(),
BorderStyle::default(),
));
if end_position.y > start_position.y + line_height {
window.paint_quad(quad(
Bounds::from_corners(
point(bounds.left(), start_position.y + line_height),
point(bounds.right(), end_position.y),
),
px(0.),
color,
Edges::default(),
gpui::transparent_black(),
BorderStyle::default(),
));
}
window.paint_quad(quad(
Bounds::from_corners(
point(bounds.left(), end_position.y),
point(end_position.x, end_position.y + line_height),
),
px(0.),
color,
Edges::default(),
gpui::transparent_black(),
BorderStyle::default(),
));
}
}
}
impl IntoElement for Inline {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for Inline {
type RequestLayoutState = ();
type PrepaintState = Hitbox;
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
global_element_id: Option<&GlobalElementId>,
inspector_id: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let text_style = self
.text_style
.clone()
.unwrap_or_else(|| window.text_style());
let runs = text_runs(self.text.len(), &text_style, &self.highlights);
let retained = take_retained_layout(&self.state).filter(|retained| {
retained.text == self.text && retained.runs == runs && retained.text_style == text_style
});
self.styled_text = match retained {
Some(retained) => retained.styled_text.with_runs(runs.clone()),
None => StyledText::new(self.text.clone()).with_runs(runs.clone()),
};
self.retained_key = Some((runs, text_style));
let (layout_id, _) =
self.styled_text
.request_layout(global_element_id, inspector_id, window, cx);
(layout_id, ())
}
fn prepaint(
&mut self,
id: Option<&GlobalElementId>,
inspector_id: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let bounds = Bounds::new(self.paint_origin.unwrap_or(bounds.origin), bounds.size);
self.styled_text
.prepaint(id, inspector_id, bounds, &mut (), window, cx);
if let Some(text_view_state) = GlobalState::global(cx).text_view_state().cloned() {
let state = text_view_state.read(cx);
if state.max_lines.is_some()
&& let Ok(mut line_spans) = state.line_spans.lock()
{
line_spans.push(LineSpan {
top: bounds.top(),
bottom: bounds.bottom(),
line_height: window.line_height(),
});
}
}
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
self.retain_styled_text();
hitbox
}
fn paint(
&mut self,
global_id: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
let bounds = Bounds::new(self.paint_origin.unwrap_or(bounds.origin), bounds.size);
let current_view = window.current_view();
let hitbox = prepaint;
if !self.reclaim_styled_text() {
return;
}
let text_layout = self.styled_text.layout().clone();
self.styled_text
.paint(global_id, None, bounds, &mut (), &mut (), window, cx);
let (is_selectable, is_selection, selection) =
self.layout_selections(&text_layout, &bounds, window, cx);
let Ok(mut state) = self.state.lock() else {
return;
};
state.selection = selection;
if let Some((source, range)) = &self.selection_source
&& let Some(selection) = selection
&& let Ok(mut source) = source.lock()
{
let start = range.start + selection.start;
let end = range.start + selection.end;
source.selection = Some(match source.selection {
Some(previous) => Selection::new(previous.start.min(start), previous.end.max(end)),
None => Selection::new(start, end),
});
}
if is_selection || is_selectable {
window.set_cursor_style(CursorStyle::IBeam, &hitbox);
}
let mouse_position = window.mouse_position();
if let Some(_) = Self::link_for_position(&text_layout, &self.links, mouse_position) {
window.set_cursor_style(CursorStyle::PointingHand, &hitbox);
}
if let Some(selection) = &state.selection {
let color = GlobalState::global(cx)
.text_view_state()
.map(|state| state.read(cx).text_view_style.selection())
.unwrap_or_else(|| crate::Theme::global(cx).tokens.colors.selection);
Self::paint_selection(selection, &text_layout, &bounds, window, color);
if let Some((start, end)) = Self::selection_edges(selection, &text_layout)
&& let Some(text_view_state) = GlobalState::global(cx).text_view_state().cloned()
{
text_view_state.update(cx, |state, _| {
state.selection_adapter.register_selection_edges(start, end);
});
}
}
if is_selectable {
if let Some(text_view_state) = GlobalState::global(cx).text_view_state().cloned() {
let text_bounds = Self::text_line_bounds(
&text_layout,
text_layout.line_height(),
window.content_mask().bounds,
);
text_view_state.update(cx, |state, _| {
state.selection_adapter.register_inline(text_bounds);
state
.selection_adapter
.register_text_run(crate::TextSelectionRun::new(
self.text.clone(),
text_layout.clone(),
hitbox.bounds,
));
});
}
window.on_mouse_event({
let hitbox = hitbox.clone();
let text_layout = text_layout.clone();
let inline_state = self.state.clone();
let text = self.text.clone();
let text_view_state = GlobalState::global(cx).text_view_state().cloned();
let line_bounds = self.selection_bounds;
move |event: &MouseDownEvent, phase, window, cx| {
if !phase.bubble()
|| !hitbox.is_hovered(window)
|| event.button != MouseButton::Left
{
return;
}
if event.click_count == 3
&& let Some(line_bounds) = line_bounds
{
GlobalState::suppress_text_selection(cx);
if let Some(view) = &text_view_state {
view.update(cx, |state, cx| {
state.set_multi_click_line(line_bounds, cx)
});
}
cx.notify(current_view);
return;
}
if event.click_count == 2 && GlobalState::is_touch_press(cx) {
GlobalState::suppress_text_selection(cx);
return;
}
let kind = match event.click_count {
2 => TextViewMultiClickKind::Word,
3 => TextViewMultiClickKind::Paragraph,
_ => return,
};
let Some(range) = selection_for_multi_click(
&text,
&text_layout,
hitbox.bounds,
event.position,
kind,
) else {
return;
};
let selected_text = text[range.clone()].to_string();
GlobalState::suppress_text_selection(cx);
if let Ok(mut inline_state) = inline_state.lock() {
inline_state.selection = Some(range.into());
}
if let Some(text_view_state) = &text_view_state {
text_view_state.update(cx, |state, cx| {
state.set_multi_click_selection(
event.position,
kind,
selected_text,
cx,
);
});
}
cx.notify(current_view);
}
});
}
window.on_mouse_event({
let hitbox = hitbox.clone();
let text_layout = text_layout.clone();
let mut hovered_index = state.hovered_index;
move |event: &MouseMoveEvent, phase, window, cx| {
if !phase.bubble() || !hitbox.is_hovered(window) {
return;
}
let current = hovered_index;
let updated = text_layout.index_for_position(event.position).ok();
if current != updated {
hovered_index = updated;
cx.notify(current_view);
}
}
});
if !is_selection {
window.on_mouse_event({
let links = self.links.clone();
let text_layout = text_layout.clone();
let hitbox = hitbox.clone();
let text_view_state = GlobalState::global(cx).text_view_state().cloned();
let link_click_handler = self.link_click_handler.clone();
move |event: &MouseUpEvent, phase, window, cx| {
if !phase.bubble() || !hitbox.is_hovered(window) {
return;
}
if text_view_state
.as_ref()
.is_some_and(|state| state.read(cx).has_selection(cx))
{
return;
}
if let Some(link) =
Self::link_for_position(&text_layout, &links, event.position)
{
TextSelection::end(window, cx);
cx.stop_propagation();
let click = ClickEvent::Mouse(MouseClickEvent {
down: MouseDownEvent {
button: event.button,
position: event.position,
modifiers: event.modifiers,
click_count: event.click_count,
first_mouse: false,
},
up: event.clone(),
});
handle_link_click(&link_click_handler, link.url, click, window, cx);
}
}
});
}
drop(state);
self.retain_styled_text();
}
}
fn selection_for_multi_click(
text: &str,
text_layout: &TextLayout,
bounds: Bounds<Pixels>,
pos: Point<Pixels>,
kind: TextViewMultiClickKind,
) -> Option<std::ops::Range<usize>> {
if !bounds.contains(&pos) {
return None;
}
let offset = text_layout.index_for_position(pos).ok()?;
match kind {
TextViewMultiClickKind::Word => word_range_at(text, offset),
TextViewMultiClickKind::Paragraph | TextViewMultiClickKind::Line => {
(!text.is_empty()).then_some(0..text.len())
}
}
}
pub(super) fn point_in_text_selection(
pos: Point<Pixels>,
char_width: Pixels,
selection_start: Point<Pixels>,
selection_end: Point<Pixels>,
line_height: Pixels,
) -> bool {
let point_in_line = |point: Point<Pixels>| point.y >= pos.y && point.y < pos.y + line_height;
let top = selection_start.y.min(selection_end.y);
let bottom = selection_start.y.max(selection_end.y);
let x = pos.x + char_width.half();
if pos.y + line_height <= top || pos.y > bottom {
return false;
}
if point_in_line(selection_start) && point_in_line(selection_end) {
let left = selection_start.x.min(selection_end.x);
let right = selection_start.x.max(selection_end.x);
return x >= left && x <= right;
}
let (top_point, bottom_point) = if selection_start.y < selection_end.y {
(selection_start, selection_end)
} else {
(selection_end, selection_start)
};
let is_top_line = point_in_line(top_point);
let is_bottom_line = point_in_line(bottom_point);
if is_top_line {
return x >= top_point.x;
} else if is_bottom_line {
return x <= bottom_point.x;
} else {
return true;
}
}
#[cfg(test)]
mod fade_highlights_tests {
use super::*;
#[test]
fn fades_text_and_background_only_inside_the_range() {
let code = InlineHighlight::from(HighlightStyle {
background_color: Some(gpui::red()),
..Default::default()
});
let combined = fade_highlights(vec![(0..4, code)], &[(2..6, 0.5)]);
let ranges: Vec<_> = combined.iter().map(|(range, _)| range.clone()).collect();
assert_eq!(ranges, vec![0..2, 2..4, 4..6]);
let (_, untouched) = &combined[0];
assert_eq!(untouched.style.fade_out, None);
assert_eq!(untouched.style.background_color.unwrap().a, 1.0);
let (_, faded_code) = &combined[1];
assert_eq!(faded_code.style.fade_out, Some(0.5));
assert_eq!(faded_code.style.background_color.unwrap().a, 0.5);
let (_, faded_text) = &combined[2];
assert_eq!(faded_text.style.fade_out, Some(0.5));
assert!(faded_text.style.background_color.is_none());
}
#[test]
fn no_fades_leave_highlights_untouched() {
let bold = InlineHighlight::from(HighlightStyle {
font_weight: Some(gpui::FontWeight::BOLD),
..Default::default()
});
let highlights = vec![(1..3, bold)];
assert_eq!(fade_highlights(highlights.clone(), &[]), highlights);
}
}
#[cfg(test)]
mod line_bounds_tests {
use super::*;
use super::{
test_draw::in_prepaint,
test_fonts::{BODY, WideMonoTextSystem},
};
use gpui::{AvailableSpace, TestApp, size};
fn by_character(
text: &str,
text_layout: &TextLayout,
line_height: Pixels,
mask_bounds: Bounds<Pixels>,
) -> Vec<Bounds<Pixels>> {
let mut line_bounds = Vec::new();
let mut current_line_y = None;
let mut current_bounds: Option<Bounds<Pixels>> = None;
let mut offset = 0;
for c in text.chars() {
let next_offset = offset + c.len_utf8();
let Some(pos) = text_layout.position_for_index(offset) else {
offset = next_offset;
continue;
};
let mut char_width = line_height.half();
if let Some(next_pos) = text_layout.position_for_index(next_offset)
&& next_pos.y == pos.y
{
char_width = next_pos.x - pos.x;
}
let bounds = Bounds::from_corners(pos, point(pos.x + char_width, pos.y + line_height))
.intersect(&mask_bounds);
if bounds.size.width > px(0.) && bounds.size.height > px(0.) {
if current_line_y == Some(pos.y) {
if let Some(current) = current_bounds.as_mut() {
*current = current.union(&bounds);
}
} else {
if let Some(current) = current_bounds.take() {
line_bounds.push(current);
}
current_line_y = Some(pos.y);
current_bounds = Some(bounds);
}
}
offset = next_offset;
}
if let Some(current) = current_bounds {
line_bounds.push(current);
}
line_bounds
}
#[test]
fn row_walk_matches_the_character_walk() {
let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
in_prepaint(&mut app, |window, cx| {
let style = TextStyle {
font_family: BODY.into(),
font_size: px(16.).into(),
..Default::default()
};
let origin = point(px(7.), px(11.));
let mask = Bounds::new(point(px(0.), px(0.)), size(px(1000.), px(1000.)));
let clipped = Bounds::new(point(px(20.), px(30.)), size(px(50.), px(60.)));
for text in [
"",
"one row",
"a long paragraph that wraps onto several rows of eight pixel glyphs",
"first line\nsecond line that also wraps around\n\nfourth",
"中文与 English 混排的一段文字也会换行",
"trailing newline\n",
] {
for wrap_width in [40., 100., 1000.] {
let runs = text_runs(text.len(), &style, &[]);
let styled =
StyledText::new(SharedString::from(text.to_string())).with_runs(runs);
let layout = styled.layout().clone();
let mut element = styled.into_any_element();
element.layout_as_root(
size(
AvailableSpace::Definite(px(wrap_width)),
AvailableSpace::MinContent,
),
window,
cx,
);
element.prepaint_at(origin, window, cx);
let line_height = layout.line_height();
for mask in [mask, clipped] {
let expected = by_character(text, &layout, line_height, mask);
let actual = Inline::text_line_bounds(&layout, line_height, mask);
let context = format!("{text:?} at {wrap_width}px in {mask:?}");
assert!(
actual.len() >= expected.len(),
"rows of {context}: {} < {}",
actual.len(),
expected.len()
);
for (row, expected) in expected.iter().enumerate() {
let actual = actual
.iter()
.find(|actual| actual.top() == expected.top())
.unwrap_or_else(|| panic!("row {row} of {context} is missing"));
assert_eq!(
actual.bottom(),
expected.bottom(),
"row {row} of {context}"
);
assert_eq!(actual.right(), expected.right(), "row {row} of {context}");
assert!(
actual.left() <= expected.left(),
"row {row} of {context} starts at {:?}, after {:?}",
actual.left(),
expected.left()
);
}
for actual in &actual {
assert_eq!(
actual.left(),
origin.x.max(mask.left()),
"left of {context}"
);
}
}
}
}
});
}
}
#[cfg(test)]
pub(super) mod test_draw {
use gpui::{App, Context, IntoElement, Render, Styled as _, TestApp, Window, canvas, px};
use std::{cell::RefCell, rc::Rc};
struct Probe {
body: Option<Box<dyn FnOnce(&mut Window, &mut App)>>,
}
impl Render for Probe {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let body = self.body.take();
canvas(
move |_, window, cx| {
if let Some(body) = body {
body(window, cx);
}
},
|_, _, _, _| {},
)
.w(px(1000.))
.h(px(1000.))
}
}
pub(crate) fn in_prepaint<R: 'static>(
app: &mut TestApp,
f: impl FnOnce(&mut Window, &mut App) -> R + 'static,
) -> R {
let slot: Rc<RefCell<Option<R>>> = Rc::new(RefCell::new(None));
let out = slot.clone();
let mut window = app.open_window(|_, _| Probe {
body: Some(Box::new(move |window, cx| {
*out.borrow_mut() = Some(f(window, cx));
})),
});
window.draw();
let value = slot.borrow_mut().take();
value.expect("prepaint probe did not run")
}
}
#[cfg(test)]
pub(super) mod test_fonts {
use gpui::{
Bounds, DevicePixels, Font, FontId, FontMetrics, FontRun, FontWeight, GlyphId, LineLayout,
Pixels, PlatformTextSystem, RenderGlyphParams, ShapedGlyph, ShapedRun, Size,
TextRenderingMode, point, px, size,
};
use std::borrow::Cow;
pub(crate) const BODY: &str = "Body";
pub(crate) const MONO: &str = "Mono";
const BODY_ID: FontId = FontId(1);
const MONO_ID: FontId = FontId(2);
const BOLD_BODY_ID: FontId = FontId(3);
const BOLD_MONO_ID: FontId = FontId(4);
const UNITS_PER_EM: f32 = 1000.;
pub(crate) struct WideMonoTextSystem;
impl WideMonoTextSystem {
fn advance_units(font_id: FontId) -> f32 {
match font_id {
MONO_ID => 1000.,
BOLD_MONO_ID => 1250.,
BODY_ID => 500.,
BOLD_BODY_ID => 750.,
_ => 500.,
}
}
pub(crate) fn width_of(text: &str, family: &str, font_size: Pixels) -> Pixels {
let font_id = if family == MONO { MONO_ID } else { BODY_ID };
font_size * (Self::advance_units(font_id) / UNITS_PER_EM) * text.chars().count() as f32
}
}
impl PlatformTextSystem for WideMonoTextSystem {
fn add_fonts(&self, _fonts: Vec<Cow<'static, [u8]>>) -> anyhow::Result<()> {
Ok(())
}
fn all_font_names(&self) -> Vec<String> {
vec![BODY.into(), MONO.into()]
}
fn font_id(&self, descriptor: &Font) -> anyhow::Result<FontId> {
Ok(
match (
descriptor.family.as_ref() == MONO,
descriptor.weight == FontWeight::BOLD,
) {
(true, true) => BOLD_MONO_ID,
(true, false) => MONO_ID,
(false, true) => BOLD_BODY_ID,
(false, false) => BODY_ID,
},
)
}
fn font_metrics(&self, _font_id: FontId) -> FontMetrics {
FontMetrics {
units_per_em: UNITS_PER_EM as u32,
ascent: 800.,
descent: -200.,
line_gap: 0.,
underline_position: -100.,
underline_thickness: 50.,
cap_height: 700.,
x_height: 500.,
bounding_box: Bounds {
origin: point(0., -200.),
size: size(1000., 1000.),
},
}
}
fn typographic_bounds(
&self,
font_id: FontId,
_glyph_id: GlyphId,
) -> anyhow::Result<Bounds<f32>> {
Ok(Bounds {
origin: point(0., 0.),
size: size(Self::advance_units(font_id), 700.),
})
}
fn advance(&self, font_id: FontId, _glyph_id: GlyphId) -> anyhow::Result<Size<f32>> {
Ok(size(Self::advance_units(font_id), 0.))
}
fn glyph_for_char(&self, _font_id: FontId, ch: char) -> Option<GlyphId> {
Some(GlyphId(ch as u32))
}
fn glyph_raster_bounds(
&self,
_params: &RenderGlyphParams,
) -> anyhow::Result<Bounds<DevicePixels>> {
Ok(Bounds::default())
}
fn rasterize_glyph(
&self,
_params: &RenderGlyphParams,
raster_bounds: Bounds<DevicePixels>,
) -> anyhow::Result<(Size<DevicePixels>, Vec<u8>)> {
Ok((raster_bounds.size, Vec::new()))
}
fn layout_line(&self, text: &str, font_size: Pixels, runs: &[FontRun]) -> LineLayout {
let mut position = px(0.);
let mut shaped_runs = Vec::new();
let mut run_start = 0;
for run in runs {
let run_text = &text[run_start..run_start + run.len];
let advance = font_size * (Self::advance_units(run.font_id) / UNITS_PER_EM);
let mut glyphs = Vec::new();
for (ix, ch) in run_text.char_indices() {
glyphs.push(ShapedGlyph {
id: GlyphId(ch as u32),
position: point(position, px(0.)),
index: run_start + ix,
is_emoji: false,
});
position += advance;
}
shaped_runs.push(ShapedRun {
font_id: run.font_id,
glyphs,
});
run_start += run.len;
}
let metrics = self.font_metrics(BODY_ID);
LineLayout {
font_size,
width: position,
ascent: font_size * (metrics.ascent / UNITS_PER_EM),
descent: font_size * (-metrics.descent / UNITS_PER_EM),
runs: shaped_runs,
len: text.len(),
}
}
fn recommended_rendering_mode(
&self,
_font_id: FontId,
_font_size: Pixels,
) -> TextRenderingMode {
TextRenderingMode::Grayscale
}
}
}
#[cfg(test)]
mod tests {
use super::{InlineHighlight, combine_highlights, point_in_text_selection, text_runs};
use gpui::{FontWeight, HighlightStyle, SharedString, TextStyle, point, px};
fn mono(style: HighlightStyle) -> InlineHighlight {
InlineHighlight {
style,
font_family: Some(SharedString::from("Mono")),
font_size_scale: None,
}
}
#[test]
fn text_runs_shape_a_code_highlight_in_its_font_family() {
let style = TextStyle {
font_family: SharedString::from("Body"),
..Default::default()
};
let highlights = vec![(4..8, mono(HighlightStyle::default()))];
let runs = text_runs(12, &style, &highlights);
let families = runs
.iter()
.map(|run| (run.len, run.font.family.as_ref()))
.collect::<Vec<_>>();
assert_eq!(families, vec![(4, "Body"), (4, "Mono"), (4, "Body")]);
}
#[test]
fn combine_highlights_cuts_a_bold_span_at_the_code_boundary() {
let bold = InlineHighlight::from(HighlightStyle {
font_weight: Some(FontWeight::BOLD),
..Default::default()
});
let combined = combine_highlights(
vec![(0..10, bold)],
vec![(6..10, mono(HighlightStyle::default()))],
);
assert_eq!(combined.len(), 2);
assert_eq!(combined[0].0, 0..6);
assert_eq!(combined[0].1.style.font_weight, Some(FontWeight::BOLD));
assert_eq!(combined[0].1.font_family, None);
assert_eq!(combined[1].0, 6..10);
assert_eq!(combined[1].1.style.font_weight, Some(FontWeight::BOLD));
assert_eq!(combined[1].1.font_family.as_deref(), Some("Mono"));
}
#[test]
fn test_point_in_text_selection() {
let line_height = px(20.);
let char_width = px(10.);
let start = point(px(50.), px(50.));
let end = point(px(150.), px(150.));
assert!(point_in_text_selection(
point(px(50.), px(40.)),
char_width,
start,
end,
line_height
));
assert!(point_in_text_selection(
point(px(50.), px(50.)),
char_width,
start,
end,
line_height
));
assert!(!point_in_text_selection(
point(px(40.), px(50.)),
char_width,
start,
end,
line_height
));
assert!(point_in_text_selection(
point(px(160.), px(50.)),
char_width,
start,
end,
line_height
));
assert!(point_in_text_selection(
point(px(100.), px(70.)),
char_width,
start,
end,
line_height
));
assert!(point_in_text_selection(
point(px(40.), px(70.)),
char_width,
start,
end,
line_height
));
assert!(point_in_text_selection(
point(px(160.), px(70.)),
char_width,
start,
end,
line_height
));
assert!(point_in_text_selection(
point(px(100.), px(140.)),
char_width,
start,
end,
line_height
));
assert!(point_in_text_selection(
point(px(40.), px(140.)),
char_width,
start,
end,
line_height
));
assert!(!point_in_text_selection(
point(px(160.), px(140.)),
char_width,
start,
end,
line_height
));
assert!(!point_in_text_selection(
point(px(100.), px(20.)),
char_width,
start,
end,
line_height
));
assert!(!point_in_text_selection(
point(px(100.), px(160.)),
char_width,
start,
end,
line_height
));
}
#[test]
fn test_point_in_text_selection_reversed_drag_direction() {
let line_height = px(20.);
let char_width = px(10.);
let start = point(px(80.), px(150.));
let end = point(px(150.), px(50.));
assert!(!point_in_text_selection(
point(px(140.), px(50.)),
char_width,
start,
end,
line_height
));
assert!(point_in_text_selection(
point(px(150.), px(50.)),
char_width,
start,
end,
line_height
));
assert!(point_in_text_selection(
point(px(75.), px(140.)),
char_width,
start,
end,
line_height
));
assert!(!point_in_text_selection(
point(px(80.), px(140.)),
char_width,
start,
end,
line_height
));
}
#[test]
fn test_point_in_text_selection_same_visual_line_with_different_y() {
let line_height = px(20.);
let char_width = px(10.);
let start = point(px(100.), px(55.));
let end = point(px(60.), px(58.));
assert!(!point_in_text_selection(
point(px(40.), px(50.)),
char_width,
start,
end,
line_height
));
assert!(point_in_text_selection(
point(px(70.), px(50.)),
char_width,
start,
end,
line_height
));
assert!(!point_in_text_selection(
point(px(110.), px(50.)),
char_width,
start,
end,
line_height
));
}
#[test]
fn test_point_in_text_selection_same_visual_line_with_reversed_y() {
let line_height = px(20.);
let char_width = px(10.);
let start = point(px(60.), px(58.));
let end = point(px(100.), px(55.));
assert!(!point_in_text_selection(
point(px(40.), px(50.)),
char_width,
start,
end,
line_height
));
assert!(point_in_text_selection(
point(px(70.), px(50.)),
char_width,
start,
end,
line_height
));
assert!(!point_in_text_selection(
point(px(110.), px(50.)),
char_width,
start,
end,
line_height
));
}
}
#[cfg(test)]
mod retained_layout_tests {
use gpui::{
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _,
TestAppContext, Window, div, px,
};
use super::RETAINED_LAYOUTS;
use crate::text::{TextView, TextViewState};
struct Twice {
state: Entity<TextViewState>,
}
impl Render for Twice {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
.w(px(300.))
.child(TextView::new(&self.state))
.child(TextView::new(&self.state))
}
}
#[gpui::test]
fn the_same_paragraph_rendered_twice_in_a_frame_keeps_one_layout_and_paints_both(
cx: &mut TestAppContext,
) {
cx.update(crate::init);
let (_, cx) = cx.add_window_view(|_, cx| Twice {
state: cx
.new(|cx| TextViewState::markdown("First paragraph.\n\nSecond **paragraph**.", cx)),
});
cx.run_until_parked();
for _ in 0..3 {
cx.update(|window, cx| window.draw(cx).clear(cx));
}
let retained = RETAINED_LAYOUTS.with(|layouts| layouts.borrow().len());
assert_eq!(retained, 2, "one layout per paragraph state");
}
}