Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions crates/base/src/input/base/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,12 +316,35 @@ impl InputModeKind for InputMode {
impl InputModeKind for TextareaMode {
const MULTI_LINE: bool = true;

/// Ordinary multi-line text needs nothing beyond the shared engine.
type Extras = ();
type Extras = TextareaExtras;

fn reset_annotations(state: &mut InputBaseState<Self>) {
state.extras.decorations.clear();
}

fn adjust_annotations(
state: &mut InputBaseState<Self>,
range: &std::ops::Range<usize>,
new_len: usize,
) {
state.extras.decorations.adjust_for_edit(range, new_len);
}
}
// `EditorMode`'s implementation lives with the editor code, next to the
// language features it dispatches to.

/// Presentation-only annotations for ordinary multi-line text.
#[derive(Default)]
pub struct TextareaExtras {
pub(crate) decorations: DecorationCollections,
}

impl InputExtras for TextareaExtras {
fn decoration_layers(&self) -> Vec<&[TextDecoration]> {
self.decorations.iter().collect()
}
}

/// What a code editor adds on top of multi-line text: language features.
pub struct EditorExtras {
pub(crate) lsp: Lsp,
Expand Down
128 changes: 103 additions & 25 deletions crates/base/src/input/editor/decorations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use gpui::{App, Context, HighlightStyle, WeakEntity};
use ropey::Rope;
use sum_tree::Bias;

use super::{InputBaseState, RopeExt as _};
use super::{InputBaseState, RopeExt as _, TextareaMode};

/// A presentation style applied to a UTF-8 byte range in an input.
///
Expand Down Expand Up @@ -33,35 +33,65 @@ struct TextDecorationCollectionId(usize);
/// [`IEditorDecorationsCollection`](https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor_editor_api.editor.IEditorDecorationsCollection.html).
#[derive(Clone, Debug)]
pub struct TextDecorationCollection {
state: WeakEntity<InputBaseState<EditorMode>>,
state: DecorationState,
id: TextDecorationCollectionId,
}

#[derive(Clone, Debug)]
enum DecorationState {
Editor(WeakEntity<InputBaseState<EditorMode>>),
Textarea(WeakEntity<InputBaseState<TextareaMode>>),
}

impl TextDecorationCollection {
/// Replace all decorations in this collection.
///
/// This corresponds to Monaco's
/// [`IEditorDecorationsCollection.set`](https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor_editor_api.editor.IEditorDecorationsCollection.html#set).
pub fn set(&self, decorations: Vec<TextDecoration>, cx: &mut App) {
let _ = self.state.update(cx, |state, cx| {
let decorations = normalize(&state.text, decorations);
if state.extras.decorations.set(self.id, decorations) {
cx.notify();
match &self.state {
DecorationState::Editor(state) => {
let _ = state.update(cx, |state, cx| {
let decorations = normalize(&state.text, decorations);
if state.extras.decorations.set(self.id, decorations) {
cx.notify();
}
});
}
});
DecorationState::Textarea(state) => {
let _ = state.update(cx, |state, cx| {
let decorations = normalize(&state.text, decorations);
if state.extras.decorations.set(self.id, decorations) {
cx.notify();
}
});
}
}
}

/// Add decorations to this collection.
///
/// This corresponds to Monaco's
/// [`IEditorDecorationsCollection.append`](https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor_editor_api.editor.IEditorDecorationsCollection.html#append).
pub fn append(&self, decorations: Vec<TextDecoration>, cx: &mut App) {
let _ = self.state.update(cx, |state, cx| {
let decorations = normalize(&state.text, decorations);
if state.extras.decorations.append(self.id, decorations) {
cx.notify();
match &self.state {
DecorationState::Editor(state) => {
let _ = state.update(cx, |state, cx| {
let decorations = normalize(&state.text, decorations);
if state.extras.decorations.append(self.id, decorations) {
cx.notify();
}
});
}
});
DecorationState::Textarea(state) => {
let _ = state.update(cx, |state, cx| {
let decorations = normalize(&state.text, decorations);
if state.extras.decorations.append(self.id, decorations) {
cx.notify();
}
});
}
}
}

/// Remove all decorations from this collection.
Expand All @@ -77,18 +107,47 @@ impl TextDecorationCollection {
/// This corresponds to Monaco's
/// [`IEditorDecorationsCollection.getRanges`](https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor_editor_api.editor.IEditorDecorationsCollection.html#getRanges).
pub fn get_ranges(&self, cx: &App) -> Vec<Range<usize>> {
self.state
.read_with(cx, |state, _| {
state
.extras
.decorations
.get(self.id)
.unwrap_or_default()
.iter()
.map(|decoration| decoration.range.clone())
.collect()
})
.unwrap_or_default()
match &self.state {
DecorationState::Editor(state) => state
.read_with(cx, |state, _| decoration_ranges(state, self.id))
.unwrap_or_default(),
DecorationState::Textarea(state) => state
.read_with(cx, |state, _| decoration_ranges(state, self.id))
.unwrap_or_default(),
}
}
}

fn decoration_ranges<M: super::InputModeKind>(
state: &InputBaseState<M>,
id: TextDecorationCollectionId,
) -> Vec<Range<usize>>
where
M::Extras: DecorationExtras,
{
state
.extras
.decorations()
.get(id)
.unwrap_or_default()
.iter()
.map(|decoration| decoration.range.clone())
.collect()
}

trait DecorationExtras {
fn decorations(&self) -> &DecorationCollections;
}

impl DecorationExtras for super::EditorExtras {
fn decorations(&self) -> &DecorationCollections {
&self.decorations
}
}

impl DecorationExtras for super::kind::TextareaExtras {
fn decorations(&self) -> &DecorationCollections {
&self.decorations
}
}

Expand Down Expand Up @@ -244,7 +303,26 @@ impl InputBaseState<EditorMode> {
let id = self.extras.decorations.create(decorations);
cx.notify();
TextDecorationCollection {
state: cx.entity().downgrade(),
state: DecorationState::Editor(cx.entity().downgrade()),
id,
}
}
}

impl InputBaseState<TextareaMode> {
/// Create an independently managed collection of text decorations.
///
/// Ranges use UTF-8 byte offsets into [`Self::value`] and follow edits.
pub fn create_decorations_collection(
&mut self,
decorations: Vec<TextDecoration>,
cx: &mut Context<Self>,
) -> TextDecorationCollection {
let decorations = normalize(&self.text, decorations);
let id = self.extras.decorations.create(decorations);
cx.notify();
TextDecorationCollection {
state: DecorationState::Textarea(cx.entity().downgrade()),
id,
}
}
Expand Down
126 changes: 105 additions & 21 deletions crates/ui/src/text/inline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub(super) struct Inline {
highlights: Vec<(Range<usize>, HighlightStyle)>,
styled_text: StyledText,
link_click_handler: Option<Arc<LinkClickHandlerFn>>,
link_underline_on_hover: bool,

state: Arc<Mutex<InlineState>>,
}
Expand All @@ -58,13 +59,40 @@ impl Inline {
id: impl Into<ElementId>,
state: Arc<Mutex<InlineState>>,
links: Vec<(Range<usize>, LinkMark)>,
highlights: Vec<(Range<usize>, HighlightStyle)>,
mut highlights: Vec<(Range<usize>, HighlightStyle)>,
link_click_handler: Option<Arc<LinkClickHandlerFn>>,
link_underline_on_hover: bool,
) -> Self {
let text = state
.lock()
.map(|state| state.text.clone())
.unwrap_or_default();
highlights.retain(|(range, _)| range.start <= range.end && range.end <= text.len());

if link_underline_on_hover
&& let Some(index) = state.lock().ok().and_then(|state| state.hovered_index)
&& let Some((range, _)) = links.iter().find(|(range, _)| range.contains(&index))
&& range.end <= text.len()
{
let underline = gpui::UnderlineStyle {
thickness: gpui::px(1.),
..Default::default()
};
if let Some((_, highlight)) = highlights
.iter_mut()
.find(|(highlight_range, _)| highlight_range == range)
{
highlight.underline = Some(underline);
} else {
highlights.push((
range.clone(),
HighlightStyle {
underline: Some(underline),
..Default::default()
},
));
}
}

Self {
id: id.into(),
Expand All @@ -73,6 +101,7 @@ impl Inline {
text: text.clone(),
styled_text: StyledText::new(text),
link_click_handler,
link_underline_on_hover,
state,
}
}
Expand Down Expand Up @@ -177,7 +206,6 @@ impl Inline {
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) {
Expand Down Expand Up @@ -361,6 +389,9 @@ impl Element for Inline {
let mut runs = Vec::new();
let mut ix = 0;
for (range, highlight) in self.highlights.iter() {
if range.start < ix || range.start > range.end || range.end > self.text.len() {
continue;
}
if ix < range.start {
runs.push(text_style.clone().to_run(range.start - ix));
}
Expand Down Expand Up @@ -518,25 +549,37 @@ impl Element for Inline {
});
}

// mouse move, update hovered link
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;
}
if self.link_underline_on_hover {
// mouse move, update hovered link
window.on_mouse_event({
let hitbox = hitbox.clone();
let text_layout = text_layout.clone();
let inline_state = self.state.clone();
let links = self.links.clone();
move |event: &MouseMoveEvent, phase, window, cx| {
if !phase.bubble() {
return;
}

let current = hovered_index;
let updated = text_layout.index_for_position(event.position).ok();
// notify update when hovering over different links
if current != updated {
hovered_index = updated;
cx.notify(current_view);
let updated = hitbox
.is_hovered(window)
.then(|| text_layout.index_for_position(event.position).ok())
.flatten()
.filter(|index| links.iter().any(|(range, _)| range.contains(index)));
let changed = inline_state.lock().ok().is_some_and(|mut state| {
if state.hovered_index == updated {
false
} else {
state.hovered_index = updated;
true
}
});
if changed {
cx.notify(current_view);
}
}
}
});
});
}

if !is_selection {
// click to open link
Expand Down Expand Up @@ -648,8 +691,49 @@ fn point_in_text_selection(

#[cfg(test)]
mod tests {
use super::point_in_text_selection;
use gpui::{point, px};
use std::sync::{Arc, Mutex};

use super::{Inline, InlineState, LinkMark, point_in_text_selection};
use gpui::{HighlightStyle, point, px};

#[test]
fn hovered_link_underline_ignores_stale_ranges() {
let state = Arc::new(Mutex::new(InlineState {
hovered_index: Some(1),
..Default::default()
}));
let inline = Inline::new(
"stale-hover",
state,
vec![(0..4, LinkMark::default())],
vec![],
None,
true,
);

assert!(inline.highlights.is_empty());
}

#[test]
fn hovered_link_underline_merges_existing_highlight() {
let state = Arc::new(Mutex::new(InlineState {
text: "link".into(),
hovered_index: Some(1),
..Default::default()
}));
let inline = Inline::new(
"hover-merge",
state,
vec![(0..4, LinkMark::default())],
vec![(0..4, HighlightStyle::default())],
None,
true,
);

assert_eq!(inline.highlights.len(), 1);
assert_eq!(inline.highlights[0].0, 0..4);
assert!(inline.highlights[0].1.underline.is_some());
}

#[test]
fn test_point_in_text_selection() {
Expand Down
Loading
Loading