reovim_plugin_cmdline_completion/
window.rs

1//! Command-line completion popup window
2//!
3//! Implements `PluginWindow` trait for rendering the wildmenu-style popup.
4
5use std::sync::Arc;
6
7use reovim_core::{
8    frame::FrameBuffer,
9    highlight::{Style, Theme},
10    plugin::{EditorContext, PluginStateRegistry, PluginWindow, Rect, WindowConfig},
11    sys::style::Color,
12};
13
14use crate::cache::{CmdlineCompletionCache, CmdlineCompletionKind};
15
16/// Get icon color for completion kind
17fn kind_color(kind: CmdlineCompletionKind) -> Color {
18    match kind {
19        CmdlineCompletionKind::Command => Color::Magenta,
20        CmdlineCompletionKind::File => Color::Cyan,
21        CmdlineCompletionKind::Directory => Color::Yellow,
22        CmdlineCompletionKind::Option => Color::Green,
23        CmdlineCompletionKind::Subcommand => Color::Blue,
24    }
25}
26
27/// Plugin window for command-line completion popup
28pub struct CmdlineCompletionWindow {
29    cache: Arc<CmdlineCompletionCache>,
30}
31
32impl CmdlineCompletionWindow {
33    /// Create a new completion window
34    #[must_use]
35    pub fn new(cache: Arc<CmdlineCompletionCache>) -> Self {
36        Self { cache }
37    }
38}
39
40impl PluginWindow for CmdlineCompletionWindow {
41    #[allow(clippy::cast_possible_truncation)]
42    fn window_config(
43        &self,
44        _state: &Arc<PluginStateRegistry>,
45        ctx: &EditorContext,
46    ) -> Option<WindowConfig> {
47        let snapshot = self.cache.load();
48
49        if !snapshot.active || snapshot.items.is_empty() {
50            return None;
51        }
52
53        // Calculate popup dimensions
54        let max_items = 10.min(snapshot.items.len());
55        let popup_height = max_items as u16;
56
57        // Calculate width based on longest item
58        // Format: " icon label    description "
59        let icon_width = 2_usize; // icon + space
60        let max_label = snapshot
61            .items
62            .iter()
63            .take(max_items)
64            .map(|i| i.label.len())
65            .max()
66            .unwrap_or(10);
67        let max_desc = snapshot
68            .items
69            .iter()
70            .take(max_items)
71            .map(|i| i.description.len())
72            .max()
73            .unwrap_or(20);
74
75        // Total: padding + icon + label + gap + description + padding
76        let popup_width =
77            (1 + icon_width + max_label.min(30) + 2 + max_desc.min(25) + 1).min(60) as u16;
78
79        // Position: above the command line (status line is at bottom row)
80        // Command line is at screen_height - 1
81        let popup_y = ctx
82            .screen_height
83            .saturating_sub(1)
84            .saturating_sub(popup_height);
85
86        // X position: align with where completion started + 1 (for colon prefix)
87        let popup_x =
88            (1 + snapshot.replace_start as u16).min(ctx.screen_width.saturating_sub(popup_width));
89
90        Some(WindowConfig {
91            bounds: Rect::new(popup_x, popup_y, popup_width, popup_height),
92            z_order: 200, // Dropdown level
93            visible: true,
94        })
95    }
96
97    #[allow(clippy::cast_possible_truncation)]
98    fn render(
99        &self,
100        _state: &Arc<PluginStateRegistry>,
101        ctx: &EditorContext,
102        buffer: &mut FrameBuffer,
103        bounds: Rect,
104        theme: &Theme,
105    ) {
106        let snapshot = self.cache.load();
107
108        if !snapshot.active || snapshot.items.is_empty() {
109            return;
110        }
111
112        let popup_x = bounds.x;
113        let popup_y = bounds.y;
114        let popup_width = bounds.width;
115        let max_items = bounds.height as usize;
116
117        // Calculate column widths
118        let icon_col_width = 2_u16; // icon + space
119        let desc_col_width = snapshot
120            .items
121            .iter()
122            .take(max_items)
123            .map(|i| i.description.len())
124            .max()
125            .unwrap_or(20)
126            .min(25) as u16;
127        let label_col_width = popup_width
128            .saturating_sub(1) // left padding
129            .saturating_sub(icon_col_width)
130            .saturating_sub(2) // gap before description
131            .saturating_sub(desc_col_width)
132            .saturating_sub(1); // right padding
133
134        // Render each item
135        for (idx, item) in snapshot.items.iter().take(max_items).enumerate() {
136            let is_selected = idx == snapshot.selected_index;
137            let base_style = if is_selected {
138                &theme.popup.selected
139            } else {
140                &theme.popup.normal
141            };
142
143            let row = popup_y + idx as u16;
144            if row >= ctx.screen_height.saturating_sub(1) {
145                break;
146            }
147
148            let mut col = popup_x;
149
150            // Left padding
151            buffer.put_char(col, row, ' ', base_style);
152            col += 1;
153
154            // Icon (colored)
155            let icon_fg = kind_color(item.kind);
156            let icon_style = if is_selected {
157                base_style.clone().fg(icon_fg)
158            } else {
159                Style::new()
160                    .fg(icon_fg)
161                    .bg(base_style.bg.unwrap_or(Color::Black))
162            };
163            for ch in item.icon.chars() {
164                buffer.put_char(col, row, ch, &icon_style);
165                col += 1;
166            }
167            // Pad to icon column width
168            while col < popup_x + 1 + icon_col_width {
169                buffer.put_char(col, row, ' ', base_style);
170                col += 1;
171            }
172
173            // Label
174            let label_chars: Vec<char> = item.label.chars().collect();
175            for ch in label_chars.iter().take(label_col_width as usize) {
176                buffer.put_char(col, row, *ch, base_style);
177                col += 1;
178            }
179            // Pad label column
180            let label_drawn = label_chars.len().min(label_col_width as usize);
181            for _ in label_drawn..label_col_width as usize {
182                buffer.put_char(col, row, ' ', base_style);
183                col += 1;
184            }
185
186            // Gap before description
187            buffer.put_char(col, row, ' ', base_style);
188            col += 1;
189            buffer.put_char(col, row, ' ', base_style);
190            col += 1;
191
192            // Description (dimmed)
193            let desc_style = if is_selected {
194                base_style.clone().dim()
195            } else {
196                Style::new()
197                    .fg(Color::DarkGrey)
198                    .bg(base_style.bg.unwrap_or(Color::Black))
199            };
200            for ch in item.description.chars().take(desc_col_width as usize) {
201                buffer.put_char(col, row, ch, &desc_style);
202                col += 1;
203            }
204            // Pad description column
205            let desc_drawn = item.description.len().min(desc_col_width as usize);
206            for _ in desc_drawn..desc_col_width as usize {
207                buffer.put_char(col, row, ' ', base_style);
208                col += 1;
209            }
210
211            // Right padding
212            buffer.put_char(col, row, ' ', base_style);
213        }
214    }
215}