diff --git a/crates/story/examples/code-editor.rs b/crates/story/examples/code-editor.rs index 57606fd1..77e375d3 100644 --- a/crates/story/examples/code-editor.rs +++ b/crates/story/examples/code-editor.rs @@ -14,8 +14,8 @@ use gpui_component::{ h_flex, highlighter::{Diagnostic, DiagnosticSeverity, Language, LanguageConfig, LanguageRegistry}, input::{ - self, CodeActionProvider, CompletionProvider, InputEvent, InputState, Position, Rope, - RopeExt, TabSize, TextInput, + self, CodeActionProvider, CompletionProvider, HoverProvider, InputEvent, InputState, + Position, Rope, RopeExt, TabSize, TextInput, }, v_flex, ActiveTheme, ContextModal, IconName, IndexPath, Selectable, Sizable, }; @@ -266,6 +266,41 @@ impl CodeActionProvider for ExampleLspStore { } } +impl HoverProvider for ExampleLspStore { + fn hover( + &self, + text: &Rope, + offset: usize, + _window: &mut Window, + _cx: &mut App, + ) -> Task>> { + let word = text.word_at(offset); + if word.is_empty() { + return Task::ready(Ok(None)); + } + + let Some(item) = self.completions.iter().find(|item| item.label == word) else { + return Task::ready(Ok(None)); + }; + + let contents = if let Some(doc) = &item.documentation { + match doc { + lsp_types::Documentation::String(s) => s.clone(), + lsp_types::Documentation::MarkupContent(mc) => mc.value.clone(), + } + } else { + "No documentation available.".to_string() + }; + + let hover = lsp_types::Hover { + contents: lsp_types::HoverContents::Scalar(lsp_types::MarkedString::String(contents)), + range: None, + }; + + Task::ready(Ok(Some(hover))) + } +} + struct TextConvertor; impl CodeActionProvider for TextConvertor { @@ -489,9 +524,10 @@ impl Example { .default_value(default_language.1) .placeholder("Enter your code here..."); - editor.lsp.completion_provider = Some(Rc::new(lsp_store.clone())); - editor.lsp.code_action_providers = - vec![Rc::new(lsp_store.clone()), Rc::new(TextConvertor)]; + let lsp_store = Rc::new(lsp_store.clone()); + editor.lsp.completion_provider = Some(lsp_store.clone()); + editor.lsp.code_action_providers = vec![lsp_store.clone(), Rc::new(TextConvertor)]; + editor.lsp.hover_provider = Some(lsp_store.clone()); editor }); diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 9a313d9f..6fa6d87e 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -390,6 +390,21 @@ impl TextElement { paths } + fn layout_hover_highlight( + &self, + last_layout: &LastLayout, + bounds: &mut Bounds, + cx: &mut App, + ) -> Option> { + let hover_popover = self.state.read(cx).hover_popover.clone(); + let Some(symbol_range) = hover_popover.map(|popover| popover.read(cx).symbol_range.clone()) + else { + return None; + }; + + Self::layout_match_range(symbol_range, last_layout, bounds) + } + fn layout_selections( &self, last_layout: &LastLayout, @@ -520,6 +535,7 @@ pub(super) struct PrepaintState { /// row index (zero based), no wrap, same line as the cursor. current_row: Option, selection_path: Option>, + hover_highlight_path: Option>, search_match_paths: Vec<(Path, bool)>, bounds: Bounds, } @@ -848,6 +864,7 @@ impl Element for TextElement { let search_match_paths = self.layout_search_matches(&last_layout, &mut bounds, cx); let selection_path = self.layout_selections(&last_layout, &mut bounds, cx); + let hover_highlight_path = self.layout_hover_highlight(&last_layout, &mut bounds, cx); let state = self.state.read(cx); let line_numbers = if state.mode.line_number() { @@ -907,6 +924,7 @@ impl Element for TextElement { current_row, selection_path, search_match_paths, + hover_highlight_path, } } @@ -1003,8 +1021,9 @@ impl Element for TextElement { // Paint selections if window.is_window_active() { + let secondary_selection = cx.theme().selection.saturation(0.1); for (path, is_active) in prepaint.search_match_paths.iter() { - window.paint_path(path.clone(), cx.theme().selection.saturation(0.1)); + window.paint_path(path.clone(), secondary_selection); if *is_active { window.paint_path(path.clone(), cx.theme().selection); @@ -1014,6 +1033,11 @@ impl Element for TextElement { if let Some(path) = prepaint.selection_path.take() { window.paint_path(path, cx.theme().selection); } + + // Paint hover highlight + if let Some(path) = prepaint.hover_highlight_path.take() { + window.paint_path(path, secondary_selection); + } } // Paint text diff --git a/crates/ui/src/input/lsp.rs b/crates/ui/src/input/lsp.rs index 51e00e48..d2b84074 100644 --- a/crates/ui/src/input/lsp.rs +++ b/crates/ui/src/input/lsp.rs @@ -8,14 +8,13 @@ use lsp_types::{ use rope::Rope; use crate::input::{ - popovers::{CodeActionItem, CodeActionMenu, CompletionMenu, ContextMenu}, + popovers::{CodeActionItem, CodeActionMenu, CompletionMenu, ContextMenu, HoverPopover}, InputState, RopeExt, }; /// LSP ServerCapabilities /// /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#serverCapabilities -#[derive(Clone, Default)] pub struct Lsp { /// The completion provider. pub completion_provider: Option>, @@ -23,9 +22,19 @@ pub struct Lsp { pub code_action_providers: Vec>, /// The hover provider. pub hover_provider: Option>, + _hover_task: Task>, } -impl Lsp {} +impl Default for Lsp { + fn default() -> Self { + Self { + completion_provider: None, + code_action_providers: vec![], + hover_provider: None, + _hover_task: Task::ready(Ok(())), + } + } +} /// A trait for providing code completions based on the current input state and context. pub trait CompletionProvider { @@ -351,4 +360,42 @@ impl InputState { self.replace_text_in_range(Some(range_utf16), &edit.new_text, window, cx); } } + + /// Handle hover trigger LSP request. + pub(super) fn handle_hover( + &mut self, + offset: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(provider) = self.lsp.hover_provider.clone() else { + return; + }; + + if let Some(hover_popover) = self.hover_popover.as_ref() { + if hover_popover.read(cx).is_same(offset) { + return; + } + } + + // Currently not implemented. + let task = provider.hover(&self.text, offset, window, cx); + let word_range = self.text.word_range(offset).unwrap_or(offset..offset); + let editor = cx.entity(); + self.lsp._hover_task = cx.spawn_in(window, async move |_, cx| { + let result = task.await?; + + _ = editor.update(cx, |editor, cx| match result { + Some(hover) => { + let hover_popover = HoverPopover::new(cx.entity(), word_range, &hover, cx); + editor.hover_popover = Some(hover_popover); + } + None => { + editor.hover_popover = None; + } + }); + + Ok(()) + }); + } } diff --git a/crates/ui/src/input/popovers/completion_menu.rs b/crates/ui/src/input/popovers/completion_menu.rs index a6ad64a6..f78e805b 100644 --- a/crates/ui/src/input/popovers/completion_menu.rs +++ b/crates/ui/src/input/popovers/completion_menu.rs @@ -1,10 +1,10 @@ use std::rc::Rc; use gpui::{ - canvas, deferred, div, prelude::FluentBuilder, px, relative, rems, Action, AnyElement, App, - AppContext, Bounds, Context, DismissEvent, Div, ElementId, Empty, Entity, EntityInputHandler, - EventEmitter, HighlightStyle, InteractiveElement as _, IntoElement, ParentElement, Pixels, - Point, Render, RenderOnce, SharedString, Stateful, Styled, StyledText, Subscription, Window, + canvas, deferred, div, prelude::FluentBuilder, px, relative, Action, AnyElement, App, + AppContext, Bounds, Context, DismissEvent, Empty, Entity, EntityInputHandler, EventEmitter, + HighlightStyle, InteractiveElement as _, IntoElement, ParentElement, Pixels, Point, Render, + RenderOnce, SharedString, Styled, StyledText, Subscription, Window, }; use lsp_types::CompletionItem; @@ -14,10 +14,13 @@ const POPOVER_GAP: Pixels = px(4.); use crate::{ actions, h_flex, - input::{self, InputState}, + input::{ + self, + popovers::{popover, render_markdown}, + InputState, + }, label::Label, list::{List, ListDelegate, ListEvent}, - text::{TextView, TextViewStyle}, ActiveTheme, IndexPath, Selectable, }; @@ -382,21 +385,6 @@ impl Render for CompletionMenu { .selected_item() .and_then(|item| item.documentation.clone()); - fn popover(id: impl Into, cx: &App) -> Stateful
{ - div() - .id(id) - .flex_none() - .occlude() - .p_1() - .text_xs() - .text_color(cx.theme().popover_foreground) - .bg(cx.theme().popover) - .border_1() - .border_color(cx.theme().border) - .rounded(cx.theme().radius) - .shadow_md() - } - let max_width = MAX_MENU_WIDTH.min(window.bounds().size.width - pos.x); let vertical_layout = pos.x + MAX_MENU_WIDTH + POPOVER_GAP + MAX_MENU_WIDTH + POPOVER_GAP > window.bounds().size.width; @@ -439,19 +427,7 @@ impl Render for CompletionMenu { popover("completion-menu", cx) .w(MAX_MENU_WIDTH) .px_2() - .child( - TextView::markdown("doc", doc, window, cx) - .style( - TextViewStyle::default() - .paragraph_gap(rems(0.5)) - .heading_font_size(|level, rem_size| match level { - 1..=3 => rem_size * 1, - 4 => rem_size * 0.9, - _ => rem_size * 0.8, - }), - ) - .selectable(), - ), + .child(render_markdown("doc", doc, window, cx)), ), ) }) diff --git a/crates/ui/src/input/popovers/diagnostic_popover.rs b/crates/ui/src/input/popovers/diagnostic_popover.rs index d4a422fc..84631e3a 100644 --- a/crates/ui/src/input/popovers/diagnostic_popover.rs +++ b/crates/ui/src/input/popovers/diagnostic_popover.rs @@ -1,11 +1,17 @@ use std::rc::Rc; use gpui::{ - canvas, deferred, div, px, App, AppContext as _, Bounds, Context, Empty, Entity, - InteractiveElement, IntoElement, ParentElement as _, Pixels, Point, Render, Styled, Window, + prelude::FluentBuilder as _, px, App, AppContext as _, Bounds, Context, Empty, Entity, + IntoElement, Pixels, Point, Render, Styled, Window, }; -use crate::{highlighter::DiagnosticEntry, input::InputState, text::TextView, ActiveTheme as _}; +use crate::{ + highlighter::DiagnosticEntry, + input::{ + popovers::{render_markdown, Popover}, + InputState, + }, +}; pub struct DiagnosticPopover { state: Entity, @@ -30,18 +36,6 @@ impl DiagnosticPopover { }) } - fn origin(&self, cx: &App) -> Option> { - let state = self.state.read(cx); - let Some(last_layout) = state.last_layout.as_ref() else { - return None; - }; - - let line_number_width = last_layout.line_number_width; - let (_, _, start_pos) = state.line_and_position_for_offset(self.diagnostic.range.start); - - start_pos.map(|pos| pos + Point::new(line_number_width, px(0.))) - } - pub(crate) fn show(&mut self, cx: &mut Context) { self.open = true; cx.notify(); @@ -70,61 +64,32 @@ impl DiagnosticPopover { } impl Render for DiagnosticPopover { - fn render(&mut self, window: &mut Window, cx: &mut gpui::Context) -> impl IntoElement { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { if !self.open { return Empty.into_any_element(); } - let view = cx.entity(); - let message = self.diagnostic.message.clone(); - let Some(pos) = self.origin(cx) else { - return Empty.into_any_element(); - }; + let (border, bg, fg) = ( self.diagnostic.severity.border(cx), self.diagnostic.severity.bg(cx), self.diagnostic.severity.fg(cx), ); - let scroll_origin = self.state.read(cx).scroll_handle.offset(); - - let y = pos.y - self.bounds.size.height + scroll_origin.y; - let x = pos.x + scroll_origin.x; - let max_width = px(500.).min(window.bounds().size.width - x); - - deferred( - div() - .id("diagnostic-popover") - .absolute() - .left(x) - .top(y) - .px_1() - .py_0p5() - .text_xs() - .max_w(max_width) - .bg(bg) - .text_color(fg) - .border_1() - .border_color(border) - .rounded(cx.theme().radius) - .shadow_md() - .child(TextView::markdown("message", message, window, cx).selectable()) - .child( - canvas( - move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds), - |_, _, _, _| {}, - ) - .top_0() - .left_0() - .absolute() - .size_full(), - ) - .on_mouse_down_out(cx.listener(|this, _, _, cx| { - this.open = false; - cx.notify(); - })), + Popover::new( + "diagnostic-popover", + self.state.clone(), + self.diagnostic.range.clone(), + move |window, cx| render_markdown("message", message.clone(), window, cx), ) + .when(!self.open, |this| this.invisible()) + .px_1() + .py_0p5() + .bg(bg) + .text_color(fg) + .border_1() + .border_color(border) .into_any_element() } } diff --git a/crates/ui/src/input/popovers/hover_popover.rs b/crates/ui/src/input/popovers/hover_popover.rs new file mode 100644 index 00000000..e486f8dd --- /dev/null +++ b/crates/ui/src/input/popovers/hover_popover.rs @@ -0,0 +1,287 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{ + deferred, div, point, prelude::FluentBuilder as _, px, AnyElement, App, AppContext as _, + AvailableSpace, Bounds, Element, ElementId, Entity, InteractiveElement, IntoElement, + MouseDownEvent, ParentElement as _, Pixels, Render, StyleRefinement, Styled, Window, +}; + +use crate::{ + input::{popovers::render_markdown, InputState}, + ActiveTheme as _, StyledExt, +}; + +pub struct HoverPopover { + editor: Entity, + /// The symbol range byte of the hover trigger. + pub(crate) symbol_range: Range, + pub(crate) hover: Rc, +} + +impl HoverPopover { + pub fn new( + editor: Entity, + symbol_range: Range, + hover: &lsp_types::Hover, + cx: &mut App, + ) -> Entity { + let hover = Rc::new(hover.clone()); + + cx.new(|_| Self { + editor, + symbol_range, + hover, + }) + } + + pub(crate) fn is_same(&self, offset: usize) -> bool { + self.symbol_range.contains(&offset) + } +} + +impl Render for HoverPopover { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + let contents = match self.hover.contents.clone() { + lsp_types::HoverContents::Scalar(scalar) => match scalar { + lsp_types::MarkedString::String(s) => s, + lsp_types::MarkedString::LanguageString(ls) => ls.value, + }, + lsp_types::HoverContents::Array(arr) => arr + .into_iter() + .map(|item| match item { + lsp_types::MarkedString::String(s) => s, + lsp_types::MarkedString::LanguageString(ls) => ls.value, + }) + .collect::>() + .join("\n\n"), + lsp_types::HoverContents::Markup(markup) => markup.value, + }; + + Popover::new( + "hover-popover", + self.editor.clone(), + self.symbol_range.clone(), + move |window, cx| render_markdown("message", contents.clone(), window, cx), + ) + .into_any_element() + } +} + +pub(crate) struct Popover { + id: ElementId, + style: StyleRefinement, + editor: Entity, + range: Range, + width_limit: Range, + content_builder: Box AnyElement>, +} + +impl Styled for Popover { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl Popover { + pub fn new( + id: impl Into, + editor: Entity, + range: Range, + f: F, + ) -> Self + where + F: Fn(&mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + Self { + id: id.into(), + editor, + range, + style: StyleRefinement::default(), + width_limit: px(200.)..px(500.), + content_builder: Box::new(move |window, cx| (f)(window, cx).into_any_element()), + } + } + + /// Get the bounds of the range in the editor, if it is visible. + fn trigger_bounds(&self, cx: &App) -> Option> { + let editor = self.editor.read(cx); + let Some(last_layout) = editor.last_layout.as_ref() else { + return None; + }; + + let Some(last_bounds) = editor.last_bounds else { + return None; + }; + + let (_, _, start_pos) = editor.line_and_position_for_offset(self.range.start); + let (_, _, end_pos) = editor.line_and_position_for_offset(self.range.end); + + let Some(start_pos) = start_pos else { + return None; + }; + let Some(end_pos) = end_pos else { + return None; + }; + + Some(Bounds::from_corners( + last_bounds.origin + start_pos, + last_bounds.origin + end_pos + point(px(0.), last_layout.line_height), + )) + } +} + +impl IntoElement for Popover { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +pub(crate) struct PopoverLayoutState { + state: Entity, + bounds: Bounds, + element: Option, +} + +impl Element for Popover { + type RequestLayoutState = PopoverLayoutState; + type PrepaintState = (); + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let open_state = window.use_keyed_state("popover-open", cx, |_, _| true); + let trigger_bounds = match self.trigger_bounds(cx) { + Some(bounds) => bounds, + None => { + return ( + div().into_any_element().request_layout(window, cx), + PopoverLayoutState { + bounds: Bounds::default(), + element: None, + state: open_state, + }, + ) + } + }; + + let max_width = self + .width_limit + .end + .min(window.bounds().size.width - SNAP_TO_EDGE * 2) + .max(px(200.)); + + let is_open = *open_state.read(cx); + + let mut popover = deferred( + div() + .when(!is_open, |s| s.invisible()) + .flex_none() + .occlude() + .p_1() + .text_xs() + .text_color(cx.theme().popover_foreground) + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .shadow_md() + .max_w(max_width) + .refine_style(&self.style) + .child((self.content_builder)(window, cx)), + ) + .into_any_element(); + + let popover_size = popover.layout_as_root(AvailableSpace::min_size(), window, cx); + const SNAP_TO_EDGE: Pixels = px(8.); + let top_space = trigger_bounds.top() - SNAP_TO_EDGE; + let right_space = window.bounds().size.width - trigger_bounds.left() - SNAP_TO_EDGE; + + let mut pos = point( + trigger_bounds.left(), + trigger_bounds.top() - popover_size.height, + ); + if popover_size.height > top_space { + pos.y = trigger_bounds.bottom(); + } + if popover_size.width > right_space { + pos.x = trigger_bounds.right() - popover_size.width; + } + + let mut empty = div().into_any_element(); + let layout_id = empty.request_layout(window, cx); + ( + layout_id, + PopoverLayoutState { + bounds: Bounds { + origin: pos, + size: popover_size, + }, + element: Some(popover), + state: open_state, + }, + ) + } + + fn prepaint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let bounds = request_layout.bounds; + let Some(popover) = request_layout.element.as_mut() else { + return; + }; + + window.with_absolute_element_offset(bounds.origin, |window| { + popover.prepaint(window, cx); + }) + } + + fn paint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + request_layout: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let bounds = request_layout.bounds; + let Some(popover) = request_layout.element.as_mut() else { + return; + }; + + popover.paint(window, cx); + + let open_state = request_layout.state.clone(); + // Mouse down out to hide. + window.on_mouse_event(move |event: &MouseDownEvent, _, _, cx| { + if !bounds.contains(&event.position) { + open_state.update(cx, |open, cx| { + *open = false; + cx.notify(); + }) + } + }) + } +} diff --git a/crates/ui/src/input/popovers/mod.rs b/crates/ui/src/input/popovers/mod.rs index b25c476f..dcd1ffcd 100644 --- a/crates/ui/src/input/popovers/mod.rs +++ b/crates/ui/src/input/popovers/mod.rs @@ -1,11 +1,22 @@ mod code_action_menu; mod completion_menu; mod diagnostic_popover; +mod hover_popover; pub(crate) use code_action_menu::*; pub(crate) use completion_menu::*; pub(crate) use diagnostic_popover::*; -use gpui::{App, Entity, IntoElement}; +pub(crate) use hover_popover::*; + +use gpui::{ + div, rems, App, Div, ElementId, Entity, InteractiveElement as _, IntoElement, SharedString, + Stateful, Styled as _, Window, +}; + +use crate::{ + text::{TextView, TextViewStyle}, + ActiveTheme as _, +}; pub(crate) enum ContextMenu { Completion(Entity), @@ -27,3 +38,37 @@ impl ContextMenu { } } } + +pub(super) fn render_markdown( + id: impl Into, + markdown: impl Into, + window: &mut Window, + cx: &mut App, +) -> impl IntoElement { + TextView::markdown(id, markdown, window, cx) + .style( + TextViewStyle::default() + .paragraph_gap(rems(0.5)) + .heading_font_size(|level, rem_size| match level { + 1..=3 => rem_size * 1, + 4 => rem_size * 0.9, + _ => rem_size * 0.8, + }), + ) + .selectable() +} + +pub(super) fn popover(id: impl Into, cx: &App) -> Stateful
{ + div() + .id(id) + .flex_none() + .occlude() + .p_1() + .text_xs() + .text_color(cx.theme().popover_foreground) + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .shadow_md() +} diff --git a/crates/ui/src/input/rope_ext.rs b/crates/ui/src/input/rope_ext.rs index 212f0d6b..c086ac8d 100644 --- a/crates/ui/src/input/rope_ext.rs +++ b/crates/ui/src/input/rope_ext.rs @@ -1,3 +1,5 @@ +use std::ops::Range; + use rope::{Point, Rope}; use crate::input::Position; @@ -43,6 +45,12 @@ pub trait RopeExt { /// Get the line, column [`Position`] (0-based) from the given byte offset. fn offset_to_position(&self, offset: usize) -> Position; + + /// Get the word byte range at the given offset (byte). + fn word_range(&self, offset: usize) -> Option>; + + /// Get word at the given offset (byte). + fn word_at(&self, offset: usize) -> String; } /// An iterator over the lines of a `Rope`. @@ -152,8 +160,48 @@ impl RopeExt for Rope { return None; } + let offset = self.clip_offset(offset, sum_tree::Bias::Left); self.slice(offset..self.len()).chars().next() } + + fn word_range(&self, offset: usize) -> Option> { + if offset >= self.len() { + return None; + } + + let offset = self.clip_offset(offset, sum_tree::Bias::Left); + + let mut left = String::new(); + for c in self.reversed_chars_at(offset) { + if c.is_alphanumeric() || c == '_' { + left.insert(0, c); + } else { + break; + } + } + let start = offset.saturating_sub(left.len()); + + let right = self + .chars_at(offset) + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect::(); + + let end = offset + right.len(); + + if start == end { + None + } else { + Some(start..end) + } + } + + fn word_at(&self, offset: usize) -> String { + if let Some(range) = self.word_range(offset) { + self.slice(range).to_string() + } else { + String::new() + } + } } #[cfg(test)] @@ -267,4 +315,24 @@ mod tests { assert_eq!(rope.char_at(38), Some('\n')); assert_eq!(rope.char_at(50), None); } + + #[test] + fn test_word_at() { + let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文 世界\nRope"); + assert_eq!(rope.word_at(0), "Hello"); + assert_eq!(rope.word_range(0), Some(0..5)); + assert_eq!(rope.word_at(8), "World"); + assert_eq!(rope.word_range(8), Some(6..11)); + assert_eq!(rope.word_at(12), ""); + assert_eq!(rope.word_range(12), None); + assert_eq!(rope.word_at(13), "This"); + assert_eq!(rope.word_range(13), Some(13..17)); + assert_eq!(rope.word_at(31), "中文"); + assert_eq!(rope.word_range(31), Some(28..34)); + assert_eq!(rope.word_at(38), "世界"); + assert_eq!(rope.word_range(38), Some(35..41)); + assert_eq!(rope.word_at(44), "Rope"); + assert_eq!(rope.word_range(44), Some(42..46)); + assert_eq!(rope.word_at(45), "Rope"); + } } diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index a3106945..f6383cba 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -30,7 +30,7 @@ use super::{ text_wrapper::TextWrapper, }; use crate::input::{ - popovers::{ContextMenu, DiagnosticPopover}, + popovers::{ContextMenu, DiagnosticPopover, HoverPopover}, search::{self, SearchPanel}, Lsp, Position, }; @@ -298,6 +298,7 @@ pub struct InputState { pub(super) context_menu: Option, /// A flag to indicate if we are currently inserting a completion item. pub(super) completion_inserting: bool, + pub(super) hover_popover: Option>, pub lsp: Lsp, @@ -382,6 +383,7 @@ impl InputState { diagnostic_popover: None, context_menu: None, completion_inserting: false, + hover_popover: None, _subscriptions, _context_menu_task: Task::ready(Ok(())), } @@ -599,7 +601,7 @@ impl InputState { let local_offset = offset.saturating_sub(prev_lines_offset); if let Some(pos) = line.position_for_index(local_offset, line_height) { let sub_line_index = (pos.y.0 / line_height.0) as usize; - let adjusted_pos = point(pos.x, pos.y + y_offset); + let adjusted_pos = point(pos.x + last_layout.line_number_width, pos.y + y_offset); return (line_index, sub_line_index, Some(adjusted_pos)); } @@ -1573,9 +1575,11 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { + // Show diagnostic popover on mouse move + let offset = self.index_for_mouse_position(event.position, window, cx); + self.handle_hover(offset, window, cx); + if self.mode.is_code_editor() { - // Show diagnostic popover on mouse move - let offset = self.index_for_mouse_position(event.position, window, cx); if let Some(diagnostic) = self .mode .diagnostics() @@ -2433,5 +2437,6 @@ impl Render for InputState { .child(TextElement::new(cx.entity().clone()).placeholder(self.placeholder.clone())) .children(self.diagnostic_popover.clone()) .children(self.context_menu.as_ref().map(|menu| menu.render())) + .children(self.hover_popover.clone()) } }