oo-ide 0.0.4

∞ is a terminal IDE focused on low distraction, high usability.
Documentation
//! Completion dropdown overlay widget.

use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::Widget,
};

use crate::operation::LspCompletionItem;

/// Map an LSP CompletionItemKind integer to a single display character.
fn kind_icon(kind: Option<u8>) -> char {
    match kind {
        Some(1) => '',  // Text
        Some(2) => 'ƒ',  // Method
        Some(3) => 'ƒ',  // Function
        Some(4) => 'ƒ',  // Constructor
        Some(5) => '',  // Field
        Some(6) => '×',  // Variable
        Some(7) => '',  // Class
        Some(8) => '',  // Interface
        Some(9) => '',  // Module
        Some(10) => '', // Property
        Some(12) => '', // Value
        Some(13) => '', // Enum
        Some(14) => '', // Keyword
        Some(15) => '', // Snippet
        Some(16) => '"', // Color
        Some(17) => '', // File
        Some(18) => '', // Reference
        Some(22) => '', // Struct
        Some(25) => 'α', // TypeParameter
        _ => '·',
    }
}

/// Stateless completion dropdown widget.
///
/// Rendered as a floating block anchored at (`anchor_x`, `anchor_y`).
/// If it would overflow the terminal bottom, it renders *above* the anchor.
pub(crate) struct CompletionWidget<'a> {
    pub items: &'a [LspCompletionItem],
    pub cursor: usize,
    /// Text typed since the trigger point; used to filter items case-insensitively.
    pub filter: &'a str,
    pub anchor_x: u16,
    pub anchor_y: u16,
    /// Full terminal area — used for boundary clamping.
    pub terminal_area: Rect,
    /// Whether completion is still loading (async LSP request in flight)
    pub loading: bool,
}

impl Widget for CompletionWidget<'_> {
    fn render(self, _area: Rect, buf: &mut Buffer) {
        let max_w: u16 = 52;
        let max_h: u16 = 10;

        // Handle loading state - show a loading message instead of items
        if self.loading {
            let term_w = self.terminal_area.width;
            let term_h = self.terminal_area.height;

            // Render below anchor
            let top = self.anchor_y + 1;
            let left = self.anchor_x.min(term_w.saturating_sub(max_w));
            let _width = max_w.min(term_w.saturating_sub(left));

            if top < term_h {
                let loading_text = "Loading...";
                let loading_style = Style::default().fg(Color::DarkGray);
                
                for (col_idx, ch) in loading_text.chars().enumerate() {
                    let x = left + col_idx as u16;
                    if x >= term_w {
                        break;
                    }
                    if let Some(cell) = buf.cell_mut((x, top)) {
                        cell.set_char(ch);
                        cell.set_style(loading_style);
                    }
                }
                return;
            }
        }

        // Filter items
        let filter_lower = self.filter.to_lowercase();
        let visible: Vec<&LspCompletionItem> = self
            .items
            .iter()
            .filter(|item| {
                filter_lower.is_empty() || item.label.to_lowercase().contains(&filter_lower)
            })
            .collect();

        if visible.is_empty() {
            return;
        }

        let row_count = (visible.len() as u16).min(max_h);
        let term_h = self.terminal_area.height;
        let term_w = self.terminal_area.width;

        // Decide whether to render below or above anchor line
        let top = if self.anchor_y + 1 + row_count <= term_h {
            self.anchor_y + 1
        } else {
            self.anchor_y.saturating_sub(row_count)
        };

        // Clamp left edge
        let left = self.anchor_x.min(term_w.saturating_sub(max_w));
        let width = max_w.min(term_w.saturating_sub(left));

        // Scroll offset so that cursor stays visible
        let scroll_offset = if self.cursor >= max_h as usize {
            self.cursor + 1 - max_h as usize
        } else {
            0
        };

        for (row_idx, item) in visible
            .iter()
            .enumerate()
            .skip(scroll_offset)
            .take(row_count as usize)
        {
            let y = top + (row_idx - scroll_offset) as u16;
            if y >= term_h {
                break;
            }

            let is_selected = row_idx == self.cursor;

            let bg = if is_selected {
                Color::DarkGray
            } else {
                Color::Reset
            };

            // Icon
            let icon = kind_icon(item.kind);

            // Label (bold if selected)
            let label_style = if is_selected {
                Style::default()
                    .fg(Color::White)
                    .bg(bg)
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(Color::White).bg(bg)
            };

            // Detail (dim)
            let detail_style = Style::default().fg(Color::DarkGray).bg(bg);

            let label_text = format!("{} {}", icon, item.label);
            let detail_text = item
                .detail
                .as_deref()
                .map(|d| format!("  {}", d))
                .unwrap_or_default();

            // Fill the row with background first
            let row_width = width as usize;
            use unicode_width::UnicodeWidthStr;

            // Determine how much of the label/detail fits in the row by columns.
            let label_w = UnicodeWidthStr::width(label_text.as_str());
            let (label_part, detail_part) = if label_w >= row_width {
                (truncate_to_width(&label_text, row_width), String::new())
            } else {
                let rem = row_width.saturating_sub(label_w);
                (label_text.to_string(), truncate_to_width(&detail_text, rem))
            };

            let line = Line::from(vec![
                Span::styled(label_part, label_style),
                Span::styled(detail_part, detail_style),
            ]);

            // Render each cell
            for (col_idx, (ch, style)) in iter_line_chars(&line).enumerate() {
                let x = left + col_idx as u16;
                if x >= term_w {
                    break;
                }
                if let Some(cell) = buf.cell_mut((x, y)) {
                    cell.set_char(ch);
                    cell.set_style(style);
                }
            }
        }
    }
}

/// Iterate over (char, Style) pairs from a ratatui Line.
fn iter_line_chars<'a>(line: &'a Line<'a>) -> impl Iterator<Item = (char, Style)> + 'a {
    line.spans.iter().flat_map(|span| {
        let style = span.style;
        span.content.chars().map(move |c| (c, style))
    })
}

fn truncate_to_width(s: &str, max_w: usize) -> String {
    
    let mut acc = String::new();
    let mut used = 0usize;
    for ch in s.chars() {
        let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
        if used + w > max_w { break; }
        acc.push(ch);
        used += w;
    }
    acc
}