oo-ide 0.0.4

∞ is a terminal IDE focused on low distraction, high usability.
Documentation
//! Button and Menu widgets.

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

pub struct Menu<'a> {
    items: &'a [&'a str],
    cursor: usize,
}

impl<'a> Menu<'a> {
    pub fn new(items: &'a [&'a str]) -> Self {
        Self { items, cursor: 0 }
    }

    pub fn cursor(mut self, cursor: usize) -> Self {
        self.cursor = cursor;
        self
    }

    pub fn rect(&self, area: Rect) -> Rect {
        let height = (self.items.len() + 2) as u16;
        let width = self.items.iter().map(|s| s.len() + 4).max().unwrap_or(18) as u16;
        Rect {
            x: area.x,
            y: area.y,
            width: width.min(area.width),
            height: height.min(area.height),
        }
    }

    pub fn hit_test(&self, position: (u16, u16), area: Rect) -> Option<usize> {
        let menu_rect = self.rect(area);
        if position.0 >= menu_rect.x
            && position.0 < menu_rect.x + menu_rect.width
            && position.1 > menu_rect.y
            && position.1 < menu_rect.y + 1 + self.items.len() as u16
        {
            Some((position.1 - menu_rect.y - 1) as usize)
        } else {
            None
        }
    }
}

impl<'a> Widget for Menu<'a> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let menu_rect = self.rect(area);
        let border_style = Style::default().fg(Color::DarkGray);
        let text_style = Style::default().fg(Color::White);

        buf.set_style(menu_rect, Style::default().bg(Color::DarkGray));

        for row in menu_rect.y..menu_rect.y + menu_rect.height {
            for col in menu_rect.x..menu_rect.x + menu_rect.width {
                if let Some(cell) = buf.cell_mut((col, row)) {
                    let is_border = row == menu_rect.y
                        || row == menu_rect.y + menu_rect.height - 1
                        || col == menu_rect.x
                        || col == menu_rect.x + menu_rect.width - 1;
                    if is_border {
                        cell.set_char(' ');
                        cell.set_style(border_style.bg(Color::Rgb(36, 36, 36)));
                    }
                }
            }
        }

        for (i, item) in self.items.iter().enumerate() {
            let item_y = menu_rect.y + 1 + i as u16;
            let is_selected = i == self.cursor;
            let item_fg = if is_selected {
                Color::Black
            } else {
                Color::White
            };
            let item_bg = if is_selected {
                Color::LightBlue
            } else {
                Color::Rgb(36, 36, 36)
            };

            // Pad each row to the full inner width so every cell gets the
            // correct background color, even when the label is shorter than
            // the widest item.
            let inner_width = (menu_rect.width as usize).saturating_sub(2);
            let label = format!("{:<width$}", format!(" {} ", item), width = inner_width);
            for (j, ch) in label.chars().take(inner_width).enumerate() {
                if let Some(cell) = buf.cell_mut((menu_rect.x + 1 + j as u16, item_y)) {
                    cell.set_char(ch);
                    cell.set_style(text_style.fg(item_fg).bg(item_bg));
                }
            }
        }
    }
}