diff --git a/crates/base/src/input/base/kind.rs b/crates/base/src/input/base/kind.rs index 26c28d535f..14b482194b 100644 --- a/crates/base/src/input/base/kind.rs +++ b/crates/base/src/input/base/kind.rs @@ -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) { + state.extras.decorations.clear(); + } + + fn adjust_annotations( + state: &mut InputBaseState, + range: &std::ops::Range, + 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, diff --git a/crates/base/src/input/editor/decorations.rs b/crates/base/src/input/editor/decorations.rs index dc4b393cce..b037142c5c 100644 --- a/crates/base/src/input/editor/decorations.rs +++ b/crates/base/src/input/editor/decorations.rs @@ -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. /// @@ -33,22 +33,40 @@ 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>, + state: DecorationState, id: TextDecorationCollectionId, } +#[derive(Clone, Debug)] +enum DecorationState { + Editor(WeakEntity>), + Textarea(WeakEntity>), +} + 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, 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. @@ -56,12 +74,24 @@ impl TextDecorationCollection { /// 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, 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. @@ -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> { - 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( + state: &InputBaseState, + id: TextDecorationCollectionId, +) -> Vec> +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 } } @@ -244,7 +303,26 @@ impl InputBaseState { let id = self.extras.decorations.create(decorations); cx.notify(); TextDecorationCollection { - state: cx.entity().downgrade(), + state: DecorationState::Editor(cx.entity().downgrade()), + id, + } + } +} + +impl InputBaseState { + /// 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, + cx: &mut Context, + ) -> TextDecorationCollection { + let decorations = normalize(&self.text, decorations); + let id = self.extras.decorations.create(decorations); + cx.notify(); + TextDecorationCollection { + state: DecorationState::Textarea(cx.entity().downgrade()), id, } } diff --git a/crates/ui/src/text/inline.rs b/crates/ui/src/text/inline.rs index 692f028f30..632604f859 100644 --- a/crates/ui/src/text/inline.rs +++ b/crates/ui/src/text/inline.rs @@ -33,6 +33,7 @@ pub(super) struct Inline { highlights: Vec<(Range, HighlightStyle)>, styled_text: StyledText, link_click_handler: Option>, + link_underline_on_hover: bool, state: Arc>, } @@ -58,13 +59,40 @@ impl Inline { id: impl Into, state: Arc>, links: Vec<(Range, LinkMark)>, - highlights: Vec<(Range, HighlightStyle)>, + mut highlights: Vec<(Range, HighlightStyle)>, link_click_handler: Option>, + 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(), @@ -73,6 +101,7 @@ impl Inline { text: text.clone(), styled_text: StyledText::new(text), link_click_handler, + link_underline_on_hover, state, } } @@ -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) { @@ -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)); } @@ -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 @@ -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() { diff --git a/crates/ui/src/text/inline_flow.rs b/crates/ui/src/text/inline_flow.rs index 3477737765..14774c13a3 100644 --- a/crates/ui/src/text/inline_flow.rs +++ b/crates/ui/src/text/inline_flow.rs @@ -290,6 +290,7 @@ impl Element for InlineFlow { links, highlights, self.link_click_handler.clone(), + false, ) .into_any_element(); element.prepaint_as_root( diff --git a/crates/ui/src/text/node.rs b/crates/ui/src/text/node.rs index 201d347e97..23970674b6 100644 --- a/crates/ui/src/text/node.rs +++ b/crates/ui/src/text/node.rs @@ -1333,6 +1333,7 @@ impl CodeBlock { vec![], self.styles(&cx.theme().highlight_theme), node_cx.link_click_handler.clone(), + false, )) .when_some(node_cx.code_block_actions.clone(), |this, actions| { this.child( @@ -1417,6 +1418,7 @@ impl Paragraph { links.clone(), highlights.clone(), node_cx.link_click_handler.clone(), + node_cx.style.link_underline_on_hover, ) .into_any_element(), ); @@ -1500,10 +1502,12 @@ impl Paragraph { if let Some(mut link_mark) = style.link.clone() { highlight.color = Some(cx.theme().link); - highlight.underline = Some(gpui::UnderlineStyle { - thickness: gpui::px(1.), - ..Default::default() - }); + if node_cx.style.link_underline { + highlight.underline = Some(gpui::UnderlineStyle { + thickness: gpui::px(1.), + ..Default::default() + }); + } // convert link references, replace link if let Some(identifier) = link_mark.identifier.as_ref() { @@ -1536,6 +1540,7 @@ impl Paragraph { links, highlights, node_cx.link_click_handler.clone(), + node_cx.style.link_underline_on_hover, ) .into_any_element(), ); @@ -1622,10 +1627,12 @@ impl Paragraph { if let Some(mut link_mark) = style.link.clone() { highlight.color = Some(cx.theme().link); - highlight.underline = Some(gpui::UnderlineStyle { - thickness: gpui::px(1.), - ..Default::default() - }); + if node_cx.style.link_underline { + highlight.underline = Some(gpui::UnderlineStyle { + thickness: gpui::px(1.), + ..Default::default() + }); + } if let Some(identifier) = link_mark.identifier.as_ref() && let Some(mark) = node_cx.link_refs.get(identifier) diff --git a/crates/ui/src/text/state.rs b/crates/ui/src/text/state.rs index eba1cbb86e..71b077f8a6 100644 --- a/crates/ui/src/text/state.rs +++ b/crates/ui/src/text/state.rs @@ -1,6 +1,6 @@ use futures::Stream as _; use std::{ - ops::RangeInclusive, + ops::{Range, RangeInclusive}, pin::Pin, sync::{Arc, Mutex}, task::Poll, @@ -21,7 +21,7 @@ use crate::{ CodeBlockActionsFn, LinkClickHandlerFn, MarkdownExtensions, TableActionsFn, TextViewStyle, document::ParsedDocument, format, - node::{self, NodeContext}, + node::{self, BlockNode, InlineNode, LinkMark, NodeContext, Paragraph, Span, TextMark}, selection_adapter::TextViewSelectionAdapter, }, v_flex, @@ -54,6 +54,24 @@ pub(super) enum TextViewFormat { Markdown, /// HTML view Html, + /// Plain text with explicit link ranges. + LinkedText, +} + +/// A byte range in plain text that should render and behave as a link. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TextViewLink { + pub range: Range, + pub url: SharedString, +} + +impl TextViewLink { + pub fn new(range: Range, url: impl Into) -> Self { + Self { + range, + url: url.into(), + } + } } /// The format of the text returned by @@ -104,6 +122,7 @@ pub struct TextViewState { pub(super) table_actions: Option>, pub(super) link_click_handler: Option>, pub(super) markdown_extensions: Arc, + linked_text_links: Arc<[TextViewLink]>, pub(super) is_selecting: bool, multi_click_selection: Option, @@ -137,6 +156,14 @@ impl TextViewState { Self::new(TextViewFormat::Html, text, cx) } + /// Create a selectable plain-text view with explicit link ranges. + pub fn linked_text(text: &str, links: Vec, cx: &mut Context) -> Self { + let mut state = Self::new(TextViewFormat::LinkedText, text, cx); + state.linked_text_links = links.into(); + state.increment_update(text, false, cx); + state + } + /// Create a new TextViewState. fn new(format: TextViewFormat, text: &str, cx: &mut Context) -> Self { let focus_handle = cx.focus_handle(); @@ -202,6 +229,7 @@ impl TextViewState { table_actions: None, link_click_handler: None, markdown_extensions: Arc::default(), + linked_text_links: Arc::default(), is_selecting: false, auto_scroll: AutoScroll::default(), selection_adapter, @@ -286,6 +314,25 @@ impl TextViewState { self.increment_update(text, false, cx); } + /// Replace plain text and its explicit link ranges as one atomic update. + pub fn set_linked_text( + &mut self, + text: &str, + links: Vec, + cx: &mut Context, + ) { + let links: Arc<[TextViewLink]> = links.into(); + if self.text == text && self.linked_text_links == links { + return; + } + + self.text.clear(); + self.text.push_str(text); + self.linked_text_links = links; + self.parsed_error = None; + self.increment_update(text, false, cx); + } + /// Append partial text content to the existing text. pub fn push_str(&mut self, new_text: &str, cx: &mut Context) { if new_text.is_empty() { @@ -328,7 +375,7 @@ impl TextViewState { fn effective_format(&self) -> SelectionFormat { match self.format { TextViewFormat::Markdown => self.selection_format, - TextViewFormat::Html => SelectionFormat::Plain, + TextViewFormat::Html | TextViewFormat::LinkedText => SelectionFormat::Plain, } } @@ -342,6 +389,9 @@ impl TextViewState { let format = self.effective_format(); if self.select_all { + if self.format == TextViewFormat::LinkedText { + return self.source().to_string(); + } if format == SelectionFormat::Source { return self.source().to_string(); } @@ -380,6 +430,7 @@ impl TextViewState { }, pending_text: text.to_string(), markdown_extensions: self.markdown_extensions.clone(), + linked_text_links: self.linked_text_links.clone(), }; // Keep small full replacements synchronous so their first layout has @@ -721,6 +772,7 @@ struct UpdateOptions { append: bool, mode: ParseMode, markdown_extensions: Arc, + linked_text_links: Arc<[TextViewLink]>, } impl UpdateOptions { @@ -810,6 +862,7 @@ fn parse_content( let new_document = match format { TextViewFormat::Markdown => format::markdown::parse(&source, &mut node_cx), TextViewFormat::Html => format::html::parse(&source, &mut node_cx), + TextViewFormat::LinkedText => parse_linked_text(&source, &options.linked_text_links), }?; if options.append { @@ -824,6 +877,49 @@ fn parse_content( Ok(content) } +fn parse_linked_text(source: &str, links: &[TextViewLink]) -> Result { + let mut paragraph = Paragraph::default(); + paragraph.set_span(Span { + start: 0, + end: source.len(), + }); + let mut cursor = 0; + + for link in links { + if link.range.start < cursor + || link.range.end > source.len() + || link.range.start >= link.range.end + || !source.is_char_boundary(link.range.start) + || !source.is_char_boundary(link.range.end) + { + continue; + } + if cursor < link.range.start { + paragraph.push(InlineNode::new(&source[cursor..link.range.start])); + } + let linked = &source[link.range.clone()]; + paragraph.push(InlineNode::new(linked).marks(vec![( + 0..linked.len(), + TextMark { + link: Some(LinkMark { + url: link.url.clone(), + ..Default::default() + }), + ..Default::default() + }, + )])); + cursor = link.range.end; + } + if cursor < source.len() { + paragraph.push(InlineNode::new(&source[cursor..])); + } + + Ok(ParsedDocument { + source: source.to_owned().into(), + blocks: vec![BlockNode::Paragraph(paragraph)].into(), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -983,6 +1079,7 @@ mod tests { append: true, mode: ParseMode::Compatible, markdown_extensions: Arc::default(), + linked_text_links: Arc::default(), }; options.merge(UpdateOptions { @@ -991,6 +1088,7 @@ mod tests { append: false, mode: ParseMode::BaselineAck, markdown_extensions: Arc::default(), + linked_text_links: Arc::default(), }); options.merge(UpdateOptions { revision: 3, @@ -998,6 +1096,7 @@ mod tests { append: true, mode: ParseMode::Compatible, markdown_extensions: Arc::default(), + linked_text_links: Arc::default(), }); assert_eq!(options.revision, 3); @@ -1013,6 +1112,7 @@ mod tests { append: false, mode: ParseMode::Replace, markdown_extensions: Arc::default(), + linked_text_links: Arc::default(), }; options.merge(UpdateOptions { @@ -1046,6 +1146,7 @@ mod tests { ParseMode::Compatible }, markdown_extensions: Arc::default(), + linked_text_links: Arc::default(), }) .unwrap(); } diff --git a/crates/ui/src/text/style.rs b/crates/ui/src/text/style.rs index 05a4604cb3..b6d273c470 100644 --- a/crates/ui/src/text/style.rs +++ b/crates/ui/src/text/style.rs @@ -43,6 +43,10 @@ pub struct TextViewStyle { /// Default is [`HighlightStyle::default()`], the `background_color` will /// fallback to `cx.theme().accent`, if it is `None`. pub inline_code: HighlightStyle, + /// Whether links are underlined at rest. Default is `true`. + pub link_underline: bool, + /// Whether links are underlined while hovered. Default is `false`. + pub link_underline_on_hover: bool, pub is_dark: bool, } @@ -64,6 +68,8 @@ impl PartialEq for TextViewStyle { && self.table_head == other.table_head && self.table_cell == other.table_cell && self.inline_code == other.inline_code + && self.link_underline == other.link_underline + && self.link_underline_on_hover == other.link_underline_on_hover && self.is_dark == other.is_dark } } @@ -80,6 +86,8 @@ impl Default for TextViewStyle { table_head: StyleRefinement::default(), table_cell: StyleRefinement::default(), inline_code: HighlightStyle::default(), + link_underline: true, + link_underline_on_hover: false, is_dark: false, } } @@ -112,6 +120,18 @@ impl TextViewStyle { self } + /// Set whether links are underlined at rest. + pub fn link_underline(mut self, underline: bool) -> Self { + self.link_underline = underline; + self + } + + /// Set whether links are underlined while hovered. + pub fn link_underline_on_hover(mut self, underline: bool) -> Self { + self.link_underline_on_hover = underline; + self + } + /// Set extra style for the table container. /// /// Set `overflow_x: scroll` on the refinement for adaptive layout: cells @@ -167,6 +187,9 @@ mod tests { let mut dark = base.clone(); dark.is_dark = true; assert!(base != dark); + + assert!(base != base.clone().link_underline(false)); + assert!(base != base.clone().link_underline_on_hover(true)); } #[test]