Skip to main content

iced_code_editor/canvas_editor/lsp_process/
overlay.rs

1//! LSP overlay UI components for displaying hover tooltips and completion menus.
2//!
3//! Provides [`LspOverlayState`] for storing overlay display state and
4//! [`view_lsp_overlay`] for rendering it on top of a [`CodeEditor`].
5
6use crate::CodeEditor;
7use iced::widget::{
8    Id, Space, button, column, container, markdown, mouse_area, row,
9    scrollable, stack, text,
10};
11use iced::{Background, Border, Color, Element, Length, Point, Shadow, Theme};
12
13/// Maximum number of completion items shown at once in the menu.
14const MAX_COMPLETION_ITEMS: usize = 8;
15/// Height in pixels of each completion item row.
16const COMPLETION_ITEM_HEIGHT: f32 = 20.0;
17/// Height in pixels of the completion menu header area.
18const COMPLETION_HEADER_HEIGHT: f32 = 24.0;
19/// Padding in pixels around the completion item list.
20const COMPLETION_PADDING: f32 = 4.0;
21/// Maximum width in pixels of the completion menu.
22const COMPLETION_MENU_WIDTH: f32 = 250.0;
23/// Border radius in pixels applied to scrollable rail and scroller borders.
24const SCROLLABLE_BORDER_RADIUS: f32 = 4.0;
25
26/// State for the LSP overlay display (hover tooltips and completion menus).
27///
28/// This struct aggregates all display-related LSP state. Instantiate once in
29/// your application and pass it to [`view_lsp_overlay`] for rendering.
30///
31/// # Example
32///
33/// ```
34/// use iced_code_editor::LspOverlayState;
35///
36/// let mut state = LspOverlayState::new();
37/// assert!(!state.hover_visible);
38/// assert!(!state.completion_visible);
39/// ```
40pub struct LspOverlayState {
41    /// The hover text received from the LSP server.
42    pub hover_text: Option<String>,
43    /// Parsed markdown items derived from `hover_text`.
44    pub hover_items: Vec<iced::widget::markdown::Item>,
45    /// Whether the hover tooltip is currently visible.
46    pub hover_visible: bool,
47    /// Screen position where the hover tooltip should be rendered.
48    pub hover_position: Option<Point>,
49    /// Whether the mouse cursor is currently over the hover tooltip.
50    pub hover_interactive: bool,
51    /// All completion items received from the LSP server.
52    pub all_completions: Vec<String>,
53    /// Current filter string applied to completion items.
54    pub completion_filter: String,
55    /// Filtered completion items to display.
56    pub completion_items: Vec<String>,
57    /// Whether the completion menu is currently visible.
58    pub completion_visible: bool,
59    /// Index of the currently selected completion item.
60    pub completion_selected: usize,
61    /// Whether completion has been suppressed after applying an item.
62    pub completion_suppressed: bool,
63    /// Screen position of the completion menu anchor.
64    pub completion_position: Option<Point>,
65}
66
67impl LspOverlayState {
68    /// Creates a new [`LspOverlayState`] with all fields at their default values.
69    ///
70    /// # Example
71    ///
72    /// ```
73    /// use iced_code_editor::LspOverlayState;
74    ///
75    /// let state = LspOverlayState::new();
76    /// assert!(!state.hover_visible);
77    /// assert!(!state.completion_visible);
78    /// ```
79    pub fn new() -> Self {
80        Self {
81            hover_text: None,
82            hover_items: Vec::new(),
83            hover_visible: false,
84            hover_position: None,
85            hover_interactive: false,
86            all_completions: Vec::new(),
87            completion_filter: String::new(),
88            completion_items: Vec::new(),
89            completion_visible: false,
90            completion_selected: 0,
91            completion_suppressed: false,
92            completion_position: None,
93        }
94    }
95
96    /// Sets the screen position where the hover tooltip should appear.
97    ///
98    /// Call this when dispatching a hover request to the LSP server.
99    ///
100    /// # Example
101    ///
102    /// ```
103    /// use iced::Point;
104    /// use iced_code_editor::LspOverlayState;
105    ///
106    /// let mut state = LspOverlayState::new();
107    /// state.set_hover_position(Point::new(100.0, 200.0));
108    /// assert_eq!(state.hover_position, Some(Point::new(100.0, 200.0)));
109    /// ```
110    pub fn set_hover_position(&mut self, point: Point) {
111        self.hover_position = Some(point);
112    }
113
114    /// Displays a hover tooltip with the given text.
115    ///
116    /// Parses the text as markdown and marks the tooltip as visible.
117    ///
118    /// # Example
119    ///
120    /// ```
121    /// use iced_code_editor::LspOverlayState;
122    ///
123    /// let mut state = LspOverlayState::new();
124    /// state.show_hover("**bold** text".to_string());
125    /// assert!(state.hover_visible);
126    /// assert!(state.hover_text.is_some());
127    /// ```
128    pub fn show_hover(&mut self, text: String) {
129        self.hover_items = iced::widget::markdown::parse(&text).collect();
130        self.hover_text = Some(text);
131        self.hover_visible = true;
132    }
133
134    /// Clears all hover-related state.
135    ///
136    /// Resets hover text, items, visibility, position, and interaction flags.
137    ///
138    /// # Example
139    ///
140    /// ```
141    /// use iced_code_editor::LspOverlayState;
142    ///
143    /// let mut state = LspOverlayState::new();
144    /// state.show_hover("some text".to_string());
145    /// state.clear_hover();
146    /// assert!(!state.hover_visible);
147    /// assert!(state.hover_text.is_none());
148    /// ```
149    pub fn clear_hover(&mut self) {
150        self.hover_text = None;
151        self.hover_items.clear();
152        self.hover_visible = false;
153        self.hover_position = None;
154        self.hover_interactive = false;
155    }
156
157    /// Sets the completion items and their display position.
158    ///
159    /// Resets the selection to index 0 and applies the current filter.
160    ///
161    /// # Example
162    ///
163    /// ```
164    /// use iced::Point;
165    /// use iced_code_editor::LspOverlayState;
166    ///
167    /// let mut state = LspOverlayState::new();
168    /// state.set_completions(
169    ///     vec!["foo".to_string(), "bar".to_string()],
170    ///     Point::ORIGIN,
171    /// );
172    /// assert_eq!(state.completion_items.len(), 2);
173    /// ```
174    pub fn set_completions(&mut self, items: Vec<String>, position: Point) {
175        self.all_completions = items;
176        self.completion_selected = 0;
177        self.completion_position = Some(position);
178        self.filter_completions();
179    }
180
181    /// Clears all completion-related state.
182    ///
183    /// # Example
184    ///
185    /// ```
186    /// use iced::Point;
187    /// use iced_code_editor::LspOverlayState;
188    ///
189    /// let mut state = LspOverlayState::new();
190    /// state.set_completions(vec!["foo".to_string()], Point::ORIGIN);
191    /// state.clear_completions();
192    /// assert!(!state.completion_visible);
193    /// assert!(state.all_completions.is_empty());
194    /// ```
195    pub fn clear_completions(&mut self) {
196        self.all_completions.clear();
197        self.completion_items.clear();
198        self.completion_filter.clear();
199        self.completion_visible = false;
200        self.completion_suppressed = false;
201    }
202
203    /// Filters `all_completions` into `completion_items` using `completion_filter`.
204    ///
205    /// Updates `completion_visible` and clamps `completion_selected` if needed.
206    ///
207    /// # Example
208    ///
209    /// ```
210    /// use iced::Point;
211    /// use iced_code_editor::LspOverlayState;
212    ///
213    /// let mut state = LspOverlayState::new();
214    /// state.set_completions(
215    ///     vec!["foo".to_string(), "bar".to_string()],
216    ///     Point::ORIGIN,
217    /// );
218    /// state.completion_filter = "fo".to_string();
219    /// state.filter_completions();
220    /// assert_eq!(state.completion_items, vec!["foo".to_string()]);
221    /// ```
222    pub fn filter_completions(&mut self) {
223        let filter = self.completion_filter.to_lowercase();
224        if filter.is_empty() {
225            self.completion_items = self.all_completions.clone();
226        } else {
227            self.completion_items = self
228                .all_completions
229                .iter()
230                .filter(|item| item.to_lowercase().contains(&filter))
231                .cloned()
232                .collect();
233        }
234        self.completion_visible = !self.completion_items.is_empty();
235        if self.completion_selected >= self.completion_items.len() {
236            self.completion_selected =
237                self.completion_items.len().saturating_sub(1);
238        }
239    }
240
241    /// Navigates through the completion list by `delta` steps, wrapping at boundaries.
242    ///
243    /// # Example
244    ///
245    /// ```
246    /// use iced::Point;
247    /// use iced_code_editor::LspOverlayState;
248    ///
249    /// let mut state = LspOverlayState::new();
250    /// state.set_completions(
251    ///     vec!["a".to_string(), "b".to_string(), "c".to_string()],
252    ///     Point::ORIGIN,
253    /// );
254    /// state.navigate(1);
255    /// assert_eq!(state.completion_selected, 1);
256    /// state.navigate(-1);
257    /// assert_eq!(state.completion_selected, 0);
258    /// ```
259    pub fn navigate(&mut self, delta: i32) {
260        if self.completion_items.is_empty() {
261            return;
262        }
263        let len = self.completion_items.len();
264        let current = self.completion_selected as i32;
265        self.completion_selected =
266            ((current + delta).rem_euclid(len as i32)) as usize;
267    }
268
269    /// Returns the currently selected completion item, if any.
270    ///
271    /// # Example
272    ///
273    /// ```
274    /// use iced::Point;
275    /// use iced_code_editor::LspOverlayState;
276    ///
277    /// let mut state = LspOverlayState::new();
278    /// state.set_completions(vec!["foo".to_string()], Point::ORIGIN);
279    /// assert_eq!(state.selected_item(), Some("foo"));
280    /// ```
281    pub fn selected_item(&self) -> Option<&str> {
282        self.completion_items.get(self.completion_selected).map(String::as_str)
283    }
284
285    /// Returns the vertical scroll offset in pixels to keep the selected
286    /// completion item visible when navigating with the keyboard.
287    ///
288    /// Pass the returned value to `scrollable::AbsoluteOffset::y`.
289    ///
290    /// # Example
291    ///
292    /// ```
293    /// use iced::Point;
294    /// use iced_code_editor::LspOverlayState;
295    ///
296    /// let mut state = LspOverlayState::new();
297    /// state.set_completions(
298    ///     vec!["a".to_string(), "b".to_string(), "c".to_string()],
299    ///     Point::ORIGIN,
300    /// );
301    /// state.navigate(2);
302    /// assert_eq!(state.scroll_offset_for_selected(), 40.0);
303    /// ```
304    pub fn scroll_offset_for_selected(&self) -> f32 {
305        self.completion_selected as f32 * COMPLETION_ITEM_HEIGHT
306    }
307}
308
309impl Default for LspOverlayState {
310    fn default() -> Self {
311        Self::new()
312    }
313}
314
315/// Messages produced by LSP overlay UI interactions.
316///
317/// Use these in your application's `update` function to handle hover
318/// and completion interactions.
319#[derive(Debug, Clone)]
320pub enum LspOverlayMessage {
321    /// The mouse cursor entered the hover tooltip area.
322    HoverEntered,
323    /// The mouse cursor exited the hover tooltip area.
324    HoverExited,
325    /// A completion item at the given index was clicked.
326    CompletionSelected(usize),
327    /// The completion menu was dismissed by clicking outside it.
328    CompletionClosed,
329    /// Navigate up in the completion list (e.g., keyboard Up arrow).
330    CompletionNavigateUp,
331    /// Navigate down in the completion list (e.g., keyboard Down arrow).
332    CompletionNavigateDown,
333    /// Confirm the currently highlighted completion item (e.g., Enter key).
334    CompletionConfirm,
335}
336
337/// Measures the maximum pixel width of any line in the given text.
338fn measure_hover_width(editor: &CodeEditor, text: &str) -> f32 {
339    text.lines().map(|line| editor.measure_text_width(line)).fold(0.0, f32::max)
340}
341
342/// Renders LSP overlay elements (hover tooltip and completion menu) on top of a [`CodeEditor`].
343///
344/// Returns an [`Element`] containing the overlays positioned relative to the editor viewport.
345/// The function maps [`LspOverlayMessage`] values to the application message type `M` via `f`.
346///
347/// # Arguments
348///
349/// * `state` — current overlay display state
350/// * `editor` — the editor this overlay is associated with (used for viewport measurements)
351/// * `theme` — the active Iced theme for styling
352/// * `font_size` — font size in points, used for markdown rendering
353/// * `line_height` — line height in pixels, used for vertical positioning
354/// * `f` — mapping function from [`LspOverlayMessage`] to the app's message type
355///
356/// # Example
357///
358/// ```no_run
359/// use iced_code_editor::{
360///     CodeEditor, LspOverlayMessage, LspOverlayState, view_lsp_overlay,
361/// };
362///
363/// struct App {
364///     editor: CodeEditor,
365///     overlay: LspOverlayState,
366/// }
367///
368/// #[derive(Clone)]
369/// enum Message {
370///     Overlay(LspOverlayMessage),
371/// }
372///
373/// fn view(app: &App) -> iced::Element<'_, Message> {
374///     view_lsp_overlay(
375///         &app.overlay,
376///         &app.editor,
377///         &iced::Theme::Dark,
378///         14.0,
379///         20.0,
380///         Message::Overlay,
381///     )
382/// }
383/// ```
384pub fn view_lsp_overlay<'a, M: Clone + 'a>(
385    state: &'a LspOverlayState,
386    editor: &'a CodeEditor,
387    theme: &'a Theme,
388    font_size: f32,
389    line_height: f32,
390    f: impl Fn(LspOverlayMessage) -> M + 'a,
391) -> Element<'a, M> {
392    // Pre-compute messages so we can clone them freely
393    let msg_hover_entered = f(LspOverlayMessage::HoverEntered);
394    let msg_hover_exited = f(LspOverlayMessage::HoverExited);
395    let msg_completion_closed = f(LspOverlayMessage::CompletionClosed);
396    let msg_completion_selected: Vec<M> = (0..state.completion_items.len())
397        .map(|i| f(LspOverlayMessage::CompletionSelected(i)))
398        .collect();
399
400    let mut has_overlay = false;
401
402    // Build the hover tooltip layer
403    let hover_layer: Element<'a, M> = build_hover_layer(
404        state,
405        editor,
406        theme,
407        (font_size, line_height),
408        msg_hover_entered,
409        msg_hover_exited,
410        &mut has_overlay,
411    );
412
413    // Build the auto-completion menu layer
414    let completion_layer: Element<'a, M> = build_completion_layer(
415        state,
416        editor,
417        line_height,
418        msg_completion_closed,
419        msg_completion_selected,
420        &mut has_overlay,
421    );
422
423    if !has_overlay {
424        return container(
425            Space::new().width(Length::Shrink).height(Length::Shrink),
426        )
427        .into();
428    }
429
430    let base = container(Space::new().width(Length::Fill).height(Length::Fill))
431        .width(Length::Fill)
432        .height(Length::Fill);
433
434    // Hover appears on top of completion
435    stack![base, completion_layer, hover_layer].into()
436}
437
438/// Builds the hover tooltip layer.
439fn build_hover_layer<'a, M: Clone + 'a>(
440    state: &'a LspOverlayState,
441    editor: &'a CodeEditor,
442    theme: &'a Theme,
443    text_metrics: (f32, f32),
444    msg_entered: M,
445    msg_exited: M,
446    has_overlay: &mut bool,
447) -> Element<'a, M> {
448    let (font_size, line_height) = text_metrics;
449    if !state.hover_visible {
450        return empty_overlay();
451    }
452
453    let Some(hover) =
454        state.hover_text.as_ref().filter(|t| !t.trim().is_empty())
455    else {
456        return empty_overlay();
457    };
458
459    let line_count = hover.lines().count().max(1);
460    let visible_lines = line_count.min(10);
461    let hover_padding = 8.0;
462    let scroll_height = line_height * visible_lines as f32
463        + (line_height * 0.75).max(10.0)
464        + hover_padding * 2.0;
465
466    let viewport_width = editor.viewport_width();
467    let max_line_width = measure_hover_width(editor, hover);
468    let max_width = (viewport_width - 24.0).max(0.0);
469    let content_max_width = if max_width > hover_padding * 2.0 {
470        max_width - hover_padding * 2.0
471    } else {
472        max_width
473    };
474    let content_width = if content_max_width > 0.0 {
475        max_line_width.min(content_max_width)
476    } else {
477        max_line_width
478    };
479    let hover_width = content_width + hover_padding * 2.0;
480
481    let palette = theme.palette();
482    let markdown_settings = markdown::Settings::with_text_size(
483        font_size,
484        markdown::Style::from_palette(palette),
485    );
486
487    let entered_for_map = msg_entered.clone();
488    let entered_for_enter = msg_entered.clone();
489    let entered_for_move = msg_entered;
490
491    let hover_content = scrollable(
492        container(
493            markdown::view(&state.hover_items, markdown_settings)
494                .map(move |_| entered_for_map.clone()),
495        )
496        .width(Length::Fixed(hover_width))
497        .padding(hover_padding),
498    )
499    .height(Length::Fixed(scroll_height))
500    .width(Length::Fixed(hover_width))
501    .style(|theme: &Theme, _status| {
502        let palette = theme.extended_palette();
503        scrollable::Style {
504            container: container::Style {
505                background: Some(Background::Color(Color::TRANSPARENT)),
506                ..container::Style::default()
507            },
508            vertical_rail: lsp_scrollable_rail(palette),
509            horizontal_rail: lsp_scrollable_rail(palette),
510            gap: None,
511            auto_scroll: scrollable::AutoScroll {
512                background: Color::TRANSPARENT.into(),
513                border: Border::default(),
514                shadow: Shadow::default(),
515                icon: Color::TRANSPARENT,
516            },
517        }
518    });
519
520    let hover_box = container(column![hover_content])
521        .width(Length::Shrink)
522        .style(|theme: &Theme| {
523            let palette = theme.extended_palette();
524            container::Style {
525                background: Some(iced::Background::Color(
526                    palette.background.weak.color,
527                )),
528                border: iced::Border {
529                    color: palette.primary.weak.color,
530                    width: 1.0,
531                    radius: 6.0.into(),
532                },
533                ..Default::default()
534            }
535        });
536
537    let hover_box: Element<'_, M> = mouse_area(hover_box)
538        .on_enter(entered_for_enter)
539        .on_move(move |_| entered_for_move.clone())
540        .on_exit(msg_exited)
541        .into();
542
543    let hover_pos = state.hover_position.unwrap_or(Point::new(4.0, 4.0));
544    let viewport_scroll = editor.viewport_scroll();
545    let hover_pos =
546        Point::new(hover_pos.x, (hover_pos.y - viewport_scroll).max(0.0));
547    let viewport_height = editor.viewport_height();
548    let gap = 1.0;
549    let show_above = hover_pos.y >= scroll_height + gap;
550
551    let gap_x = (editor.char_width() * 0.5).max(2.0);
552    let right_x = hover_pos.x + gap_x;
553    let left_x = hover_pos.x - hover_width - gap_x;
554    let max_x = (viewport_width - hover_width - 4.0).max(0.0);
555
556    let offset_x = if right_x <= max_x {
557        right_x
558    } else if left_x >= 0.0 {
559        left_x
560    } else {
561        right_x.clamp(0.0, max_x)
562    };
563
564    let offset_y = if show_above {
565        (hover_pos.y - scroll_height - gap).max(0.0)
566    } else {
567        (hover_pos.y + line_height + gap).max(0.0).min(viewport_height)
568    };
569
570    *has_overlay = true;
571
572    container(
573        column![
574            Space::new().height(Length::Fixed(offset_y)),
575            row![Space::new().width(Length::Fixed(offset_x)), hover_box]
576        ]
577        .spacing(0)
578        .width(Length::Fill)
579        .height(Length::Fill),
580    )
581    .width(Length::Fill)
582    .height(Length::Fill)
583    .into()
584}
585
586/// Builds the auto-completion menu layer.
587fn build_completion_layer<'a, M: Clone + 'a>(
588    state: &'a LspOverlayState,
589    editor: &'a CodeEditor,
590    line_height: f32,
591    msg_closed: M,
592    msg_selected: Vec<M>,
593    has_overlay: &mut bool,
594) -> Element<'a, M> {
595    if !state.completion_visible
596        || state.completion_items.is_empty()
597        || state.completion_suppressed
598    {
599        return empty_overlay();
600    }
601
602    let visible_count = state.completion_items.len().min(MAX_COMPLETION_ITEMS);
603    let menu_height = COMPLETION_HEADER_HEIGHT
604        + (visible_count as f32 * COMPLETION_ITEM_HEIGHT)
605        + (COMPLETION_PADDING * 2.0);
606
607    let cursor_pos = state.completion_position.unwrap_or(Point::new(4.0, 4.0));
608    let viewport_width = editor.viewport_width();
609    let viewport_height = editor.viewport_height();
610    let viewport_scroll = editor.viewport_scroll();
611
612    let menu_width = COMPLETION_MENU_WIDTH.min(viewport_width - 8.0);
613    let adjusted_y = (cursor_pos.y - viewport_scroll).max(0.0);
614    let space_below = viewport_height - adjusted_y - line_height;
615    let show_above =
616        space_below < menu_height + 4.0 && adjusted_y >= menu_height + 4.0;
617
618    let offset_x = cursor_pos.x.min(viewport_width - menu_width - 4.0).max(4.0);
619    let offset_y = if show_above {
620        (adjusted_y - menu_height - 4.0).max(0.0)
621    } else {
622        adjusted_y + line_height + 4.0
623    };
624
625    let completion_elements: Vec<Element<'_, M>> = state
626        .completion_items
627        .iter()
628        .enumerate()
629        .zip(msg_selected)
630        .map(|((index, item), msg)| {
631            let is_selected = index == state.completion_selected;
632            button(
633                text(item.clone())
634                    .size(12)
635                    .line_height(iced::widget::text::LineHeight::Relative(1.5)),
636            )
637            .padding([2, 8])
638            .width(Length::Fill)
639            .on_press(msg)
640            .style(move |theme: &Theme, _status| {
641                let palette = theme.extended_palette();
642                if is_selected {
643                    button::Style {
644                        background: Some(iced::Background::Color(
645                            palette.primary.weak.color,
646                        )),
647                        text_color: Color::WHITE,
648                        ..Default::default()
649                    }
650                } else {
651                    button::Style {
652                        background: Some(iced::Background::Color(
653                            palette.background.weak.color,
654                        )),
655                        text_color: Color::WHITE,
656                        ..Default::default()
657                    }
658                }
659            })
660            .into()
661        })
662        .collect();
663
664    let completion_box = scrollable(column(completion_elements).spacing(0))
665        .height(Length::Fixed(menu_height))
666        .width(Length::Fixed(menu_width))
667        .id(Id::new("completion_scrollable"))
668        .style(|theme: &Theme, _status| {
669            let palette = theme.extended_palette();
670            scrollable::Style {
671                container: container::Style {
672                    background: Some(iced::Background::Color(
673                        palette.background.weak.color,
674                    )),
675                    border: iced::Border {
676                        color: palette.primary.weak.color,
677                        width: 1.0,
678                        radius: SCROLLABLE_BORDER_RADIUS.into(),
679                    },
680                    ..Default::default()
681                },
682                vertical_rail: lsp_scrollable_rail(palette),
683                horizontal_rail: lsp_scrollable_rail(palette),
684                gap: None,
685                auto_scroll: scrollable::AutoScroll {
686                    background: Color::TRANSPARENT.into(),
687                    border: Border::default(),
688                    shadow: Shadow::default(),
689                    icon: Color::TRANSPARENT,
690                },
691            }
692        });
693
694    *has_overlay = true;
695
696    let click_outside =
697        button(Space::new().width(Length::Fill).height(Length::Fill))
698            .width(Length::Fill)
699            .height(Length::Fill)
700            .on_press(msg_closed)
701            .style(|_theme: &Theme, _status| button::Style {
702                background: Some(Background::Color(Color::TRANSPARENT)),
703                ..Default::default()
704            });
705
706    let completion_content = container(
707        column![
708            Space::new().height(Length::Fixed(offset_y)),
709            row![Space::new().width(Length::Fixed(offset_x)), completion_box]
710        ]
711        .spacing(0)
712        .width(Length::Fill)
713        .height(Length::Fill),
714    )
715    .width(Length::Fill)
716    .height(Length::Fill);
717
718    stack![click_outside, completion_content].into()
719}
720
721/// Creates the scrollable rail style used in LSP overlay panels.
722///
723/// Both the hover tooltip and the completion menu share the same rail appearance:
724/// a translucent background track with a primary-coloured scroller and no border.
725fn lsp_scrollable_rail(
726    palette: &iced::theme::palette::Extended,
727) -> scrollable::Rail {
728    scrollable::Rail {
729        background: Some(palette.background.weak.color.into()),
730        border: Border {
731            radius: SCROLLABLE_BORDER_RADIUS.into(),
732            width: 0.0,
733            color: Color::TRANSPARENT,
734        },
735        scroller: scrollable::Scroller {
736            background: palette.primary.weak.color.into(),
737            border: Border {
738                radius: SCROLLABLE_BORDER_RADIUS.into(),
739                width: 0.0,
740                color: Color::TRANSPARENT,
741            },
742        },
743    }
744}
745
746/// Returns a zero-size placeholder element.
747fn empty_overlay<'a, M: 'a>() -> Element<'a, M> {
748    container(Space::new().width(Length::Shrink).height(Length::Shrink)).into()
749}
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754    use iced::Point;
755
756    #[test]
757    fn test_lsp_overlay_state_new() {
758        let state = LspOverlayState::new();
759        assert!(!state.hover_visible);
760        assert!(!state.completion_visible);
761        assert!(state.hover_text.is_none());
762        assert!(state.completion_items.is_empty());
763    }
764
765    #[test]
766    fn test_show_hover() {
767        let mut state = LspOverlayState::new();
768        state.show_hover("hello".to_string());
769        assert!(state.hover_visible);
770        assert_eq!(state.hover_text, Some("hello".to_string()));
771    }
772
773    #[test]
774    fn test_clear_hover() {
775        let mut state = LspOverlayState::new();
776        state.show_hover("hello".to_string());
777        state.hover_interactive = true;
778        state.hover_position = Some(Point::ORIGIN);
779        state.clear_hover();
780        assert!(!state.hover_visible);
781        assert!(state.hover_text.is_none());
782        assert!(!state.hover_interactive);
783        assert!(state.hover_position.is_none());
784    }
785
786    #[test]
787    fn test_set_hover_position() {
788        let mut state = LspOverlayState::new();
789        state.set_hover_position(Point::new(10.0, 20.0));
790        assert_eq!(state.hover_position, Some(Point::new(10.0, 20.0)));
791    }
792
793    #[test]
794    fn test_set_completions() {
795        let mut state = LspOverlayState::new();
796        state.set_completions(
797            vec!["foo".to_string(), "bar".to_string()],
798            Point::ORIGIN,
799        );
800        assert_eq!(state.completion_items.len(), 2);
801        assert!(state.completion_visible);
802        assert_eq!(state.completion_selected, 0);
803    }
804
805    #[test]
806    fn test_clear_completions() {
807        let mut state = LspOverlayState::new();
808        state.set_completions(vec!["foo".to_string()], Point::ORIGIN);
809        state.clear_completions();
810        assert!(!state.completion_visible);
811        assert!(state.all_completions.is_empty());
812        assert!(state.completion_items.is_empty());
813    }
814
815    #[test]
816    fn test_filter_completions() {
817        let mut state = LspOverlayState::new();
818        state.set_completions(
819            vec!["foo".to_string(), "bar".to_string(), "baz".to_string()],
820            Point::ORIGIN,
821        );
822        state.completion_filter = "ba".to_string();
823        state.filter_completions();
824        assert_eq!(state.completion_items.len(), 2);
825        assert!(state.completion_items.contains(&"bar".to_string()));
826        assert!(state.completion_items.contains(&"baz".to_string()));
827    }
828
829    #[test]
830    fn test_navigate() {
831        let mut state = LspOverlayState::new();
832        state.set_completions(
833            vec!["a".to_string(), "b".to_string(), "c".to_string()],
834            Point::ORIGIN,
835        );
836        state.navigate(1);
837        assert_eq!(state.completion_selected, 1);
838        state.navigate(-1);
839        assert_eq!(state.completion_selected, 0);
840        // Wrap around going up
841        state.navigate(-1);
842        assert_eq!(state.completion_selected, 2);
843        // Wrap around going down
844        state.navigate(1);
845        assert_eq!(state.completion_selected, 0);
846    }
847
848    #[test]
849    fn test_scroll_offset_for_selected() {
850        let mut state = LspOverlayState::new();
851        assert_eq!(state.scroll_offset_for_selected(), 0.0);
852        state.set_completions(
853            vec!["a".to_string(), "b".to_string(), "c".to_string()],
854            Point::ORIGIN,
855        );
856        assert_eq!(state.scroll_offset_for_selected(), 0.0);
857        state.navigate(1);
858        assert_eq!(state.scroll_offset_for_selected(), COMPLETION_ITEM_HEIGHT);
859        state.navigate(1);
860        assert_eq!(
861            state.scroll_offset_for_selected(),
862            2.0 * COMPLETION_ITEM_HEIGHT
863        );
864    }
865
866    #[test]
867    fn test_selected_item() {
868        let mut state = LspOverlayState::new();
869        assert_eq!(state.selected_item(), None);
870        state.set_completions(
871            vec!["first".to_string(), "second".to_string()],
872            Point::ORIGIN,
873        );
874        assert_eq!(state.selected_item(), Some("first"));
875        state.navigate(1);
876        assert_eq!(state.selected_item(), Some("second"));
877    }
878}