use std::{ops::Range, rc::Rc}; use gpui::{ AnyElement, App, AppContext as _, AvailableSpace, Bounds, Element, ElementId, Entity, InteractiveElement, IntoElement, MouseDownEvent, MouseMoveEvent, ParentElement as _, Pixels, Render, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, deferred, div, point, px, }; use crate::{ StyledExt, input::{InputState, popovers::render_markdown}, }; 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 { 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 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, }, ); } }; let max_width = self .width_limit .end .min(window.bounds().size.width - SNAP_TO_EDGE * 2) .max(px(200.)); let max_height = (window.bounds().size.height - SNAP_TO_EDGE * 2).min(px(320.)); let mut popover = deferred( div() .id("hover-popover-content") .flex_none() .occlude() .p_1() .text_xs() .popover_style(cx) .shadow_md() .max_w(max_width) .max_h(max_height) .overflow_y_scroll() .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), }, ) } 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 editor = self.editor.clone(); // Mouse down out to hide. window.on_mouse_event(move |event: &MouseDownEvent, _, _, cx| { if !bounds.contains(&event.position) { let _ = editor.update(cx, |editor, cx| { editor.clear_hover_state(cx); }); } }); // Mouse out of trigger + popover bounds let editor = self.editor.clone(); let trigger_bounds = self.trigger_bounds(cx).unwrap_or(bounds); let keep_open_region = trigger_bounds.union(&bounds); window.on_mouse_event(move |event: &MouseMoveEvent, _, _, cx| { if !keep_open_region.contains(&event.position) { let _ = editor.update(cx, |editor, cx| { editor.clear_hover_state(cx); }); } }) } }