reovim_plugin_completion/
window.rs

1//! Completion popup window
2//!
3//! Plugin window that renders the completion dropdown.
4//! Reads from the lock-free cache for non-blocking rendering.
5//! Also renders ghost text inline at cursor position.
6
7use std::{collections::HashSet, sync::Arc};
8
9use reovim_core::{
10    completion::CompletionKind,
11    frame::FrameBuffer,
12    highlight::{Style, Theme},
13    plugin::{EditorContext, PluginStateRegistry, PluginWindow, Rect, WindowConfig},
14    sys::style::Color,
15};
16
17use crate::state::SharedCompletionManager;
18
19/// Get short abbreviation for completion kind (2-3 chars)
20fn kind_abbrev(kind: CompletionKind) -> &'static str {
21    match kind {
22        CompletionKind::Text => "txt",
23        CompletionKind::Method => "fn",
24        CompletionKind::Function => "fn",
25        CompletionKind::Constructor => "new",
26        CompletionKind::Field => "fld",
27        CompletionKind::Variable => "var",
28        CompletionKind::Class => "cls",
29        CompletionKind::Interface => "int",
30        CompletionKind::Module => "mod",
31        CompletionKind::Property => "prp",
32        CompletionKind::Unit => "unt",
33        CompletionKind::Value => "val",
34        CompletionKind::Enum => "enm",
35        CompletionKind::Keyword => "kw",
36        CompletionKind::Snippet => "snp",
37        CompletionKind::Color => "clr",
38        CompletionKind::File => "fil",
39        CompletionKind::Reference => "ref",
40        CompletionKind::Folder => "dir",
41        CompletionKind::EnumMember => "enm",
42        CompletionKind::Constant => "cst",
43        CompletionKind::Struct => "st",
44        CompletionKind::Event => "evt",
45        CompletionKind::Operator => "op",
46        CompletionKind::TypeParameter => "typ",
47    }
48}
49
50/// Get color for completion kind
51fn kind_color(kind: CompletionKind) -> Color {
52    match kind {
53        CompletionKind::Function | CompletionKind::Method | CompletionKind::Constructor => {
54            Color::Magenta
55        }
56        CompletionKind::Variable | CompletionKind::Field | CompletionKind::Property => Color::Cyan,
57        CompletionKind::Module | CompletionKind::Class | CompletionKind::Interface => Color::Yellow,
58        CompletionKind::Struct | CompletionKind::Enum | CompletionKind::EnumMember => Color::Green,
59        CompletionKind::Keyword => Color::Blue,
60        CompletionKind::Snippet => Color::Red,
61        CompletionKind::Constant => Color::Cyan,
62        _ => Color::White,
63    }
64}
65
66/// Plugin window for completion popup
67///
68/// Reads from the lock-free cache for rendering.
69pub struct CompletionPluginWindow {
70    manager: Arc<SharedCompletionManager>,
71}
72
73impl CompletionPluginWindow {
74    /// Create a new completion window
75    #[must_use]
76    pub fn new(manager: Arc<SharedCompletionManager>) -> Self {
77        Self { manager }
78    }
79}
80
81impl PluginWindow for CompletionPluginWindow {
82    #[allow(clippy::cast_possible_truncation)]
83    fn window_config(
84        &self,
85        _state: &Arc<PluginStateRegistry>,
86        ctx: &EditorContext,
87    ) -> Option<WindowConfig> {
88        let snapshot = self.manager.snapshot();
89
90        if !snapshot.active || snapshot.items.is_empty() {
91            return None;
92        }
93
94        // Transform buffer coordinates to screen coordinates
95        // Account for: window anchor, line number gutter, and scroll offset
96        let display_row = (snapshot.cursor_row as u16).saturating_sub(ctx.active_window_scroll_y);
97        let screen_y = ctx.active_window_anchor_y + display_row;
98        let screen_x = ctx
99            .active_window_anchor_x
100            .saturating_add(ctx.active_window_gutter_width)
101            .saturating_add(snapshot.word_start_col as u16);
102
103        let max_items = 10.min(snapshot.items.len());
104
105        // Calculate widths for each column
106        // Format: " [kind] label    [source] "
107        let kind_width = 4_usize; // "fn " or "mod" + space
108        let max_label_width = snapshot
109            .items
110            .iter()
111            .take(max_items)
112            .map(|i| i.label.len())
113            .max()
114            .unwrap_or(8);
115        let max_source_width = snapshot
116            .items
117            .iter()
118            .take(max_items)
119            .map(|i| i.source.len())
120            .max()
121            .unwrap_or(6);
122
123        // Total: space + kind + label + gap + source + space
124        let popup_width =
125            (1 + kind_width + max_label_width.min(30) + 1 + max_source_width.min(12) + 1).min(60)
126                as u16;
127
128        // Use EditorContext::dropdown() for proper bounds clamping
129        let (popup_x, popup_y, _, popup_height) =
130            ctx.dropdown(screen_x, screen_y, popup_width, max_items as u16);
131
132        Some(WindowConfig {
133            bounds: Rect::new(popup_x, popup_y, popup_width, popup_height),
134            z_order: 200, // Completion dropdown
135            visible: true,
136        })
137    }
138
139    #[allow(clippy::cast_possible_truncation)]
140    fn render(
141        &self,
142        _state: &Arc<PluginStateRegistry>,
143        ctx: &EditorContext,
144        buffer: &mut FrameBuffer,
145        bounds: Rect,
146        theme: &Theme,
147    ) {
148        let snapshot = self.manager.snapshot();
149
150        if !snapshot.active || snapshot.items.is_empty() {
151            return;
152        }
153
154        let popup_x = bounds.x;
155        let popup_y = bounds.y;
156        let popup_width = bounds.width;
157        let max_items = bounds.height as usize;
158
159        // Calculate column widths for rendering
160        let kind_col_width = 4_u16; // "fn " + space
161        let source_col_width = snapshot
162            .items
163            .iter()
164            .take(max_items)
165            .map(|i| i.source.len())
166            .max()
167            .unwrap_or(6)
168            .min(12) as u16;
169        let label_col_width = popup_width
170            .saturating_sub(1) // left padding
171            .saturating_sub(kind_col_width)
172            .saturating_sub(1) // gap before source
173            .saturating_sub(source_col_width)
174            .saturating_sub(1); // right padding
175
176        // Render the popup menu
177        for (idx, item) in snapshot.items.iter().take(max_items).enumerate() {
178            let is_selected = idx == snapshot.selected_index;
179            let base_style = if is_selected {
180                &theme.popup.selected
181            } else {
182                &theme.popup.normal
183            };
184
185            let row = popup_y + idx as u16;
186            if row >= ctx.screen_height.saturating_sub(1) {
187                break;
188            }
189
190            let mut col = popup_x;
191
192            // Left padding
193            buffer.put_char(col, row, ' ', base_style);
194            col += 1;
195
196            // Kind abbreviation (colored)
197            let kind_abbr = kind_abbrev(item.kind);
198            let kind_fg = kind_color(item.kind);
199            let kind_style = if is_selected {
200                base_style.clone().fg(kind_fg)
201            } else {
202                Style::new()
203                    .fg(kind_fg)
204                    .bg(base_style.bg.unwrap_or(Color::Black))
205            };
206            for ch in kind_abbr.chars() {
207                buffer.put_char(col, row, ch, &kind_style);
208                col += 1;
209            }
210            // Pad kind column
211            for _ in kind_abbr.len()..kind_col_width as usize {
212                buffer.put_char(col, row, ' ', base_style);
213                col += 1;
214            }
215
216            // Label with match highlighting
217            let matched_set: HashSet<u32> = item.match_indices.iter().copied().collect();
218            let label_chars: Vec<char> = item.label.chars().collect();
219            for (i, &ch) in label_chars
220                .iter()
221                .take(label_col_width as usize)
222                .enumerate()
223            {
224                let char_style = if matched_set.contains(&(i as u32)) {
225                    let match_fg_color = theme.popup.match_fg.fg.unwrap_or(Color::Yellow);
226                    if is_selected {
227                        base_style.clone().fg(match_fg_color)
228                    } else {
229                        let bg_color = base_style.bg.unwrap_or(Color::Black);
230                        theme.popup.match_fg.clone().bg(bg_color)
231                    }
232                } else {
233                    base_style.clone()
234                };
235                buffer.put_char(col, row, ch, &char_style);
236                col += 1;
237            }
238            // Pad label column
239            for _ in label_chars.len().min(label_col_width as usize)..label_col_width as usize {
240                buffer.put_char(col, row, ' ', base_style);
241                col += 1;
242            }
243
244            // Gap before source
245            buffer.put_char(col, row, ' ', base_style);
246            col += 1;
247
248            // Source (dimmed)
249            let source_style = if is_selected {
250                base_style.clone().dim()
251            } else {
252                Style::new()
253                    .fg(Color::DarkGrey)
254                    .bg(base_style.bg.unwrap_or(Color::Black))
255            };
256            for ch in item.source.chars().take(source_col_width as usize) {
257                buffer.put_char(col, row, ch, &source_style);
258                col += 1;
259            }
260            // Pad source column
261            for _ in item.source.len().min(source_col_width as usize)..source_col_width as usize {
262                buffer.put_char(col, row, ' ', base_style);
263                col += 1;
264            }
265
266            // Right padding
267            buffer.put_char(col, row, ' ', base_style);
268        }
269
270        // Render ghost text inline at cursor position
271        self.render_ghost_text(&snapshot, ctx, buffer, bounds);
272    }
273}
274
275impl CompletionPluginWindow {
276    /// Render ghost text (remaining completion text) inline at cursor position
277    #[allow(clippy::cast_possible_truncation)]
278    fn render_ghost_text(
279        &self,
280        snapshot: &crate::cache::CompletionSnapshot,
281        ctx: &EditorContext,
282        buffer: &mut FrameBuffer,
283        _bounds: Rect,
284    ) {
285        // Get the selected item
286        let Some(item) = snapshot.selected_item() else {
287            return;
288        };
289
290        // Calculate ghost text: the part of insert_text not yet typed
291        // insert_text is the full text, prefix is what's already typed
292        let ghost_text = if item.insert_text.len() > snapshot.prefix.len()
293            && item.insert_text.starts_with(&snapshot.prefix)
294        {
295            &item.insert_text[snapshot.prefix.len()..]
296        } else if item.label.len() > snapshot.prefix.len()
297            && item
298                .label
299                .to_lowercase()
300                .starts_with(&snapshot.prefix.to_lowercase())
301        {
302            // Fallback to label if insert_text doesn't match
303            &item.label[snapshot.prefix.len()..]
304        } else {
305            return;
306        };
307
308        if ghost_text.is_empty() {
309            return;
310        }
311
312        // Ghost text style: dim grey
313        let ghost_style = Style::new().fg(Color::DarkGrey).dim();
314
315        // Calculate screen coordinates using EditorContext
316        // Ghost text renders at cursor position (after the typed prefix)
317        let ghost_y = ctx.cursor_screen_y();
318        let ghost_x = ctx.cursor_screen_x();
319
320        // Don't render past screen width
321        let max_width = ctx.screen_width.saturating_sub(ghost_x);
322
323        for (i, ch) in ghost_text.chars().take(max_width as usize).enumerate() {
324            buffer.put_char(ghost_x + i as u16, ghost_y, ch, &ghost_style);
325        }
326    }
327}