lv-tui 0.4.0

A reactive TUI framework for Rust
Documentation
use crate::component::{Component, EventCx, MeasureCx};
use crate::event::Event;
use crate::geom::{Rect, Size};
use crate::layout::Constraint;
use crate::render::RenderCx;
use crate::style::Style;
use crate::text::Text;

/// A dropdown select widget.
///
/// Displays the currently selected option when collapsed ("▼"), and expands
/// inline to show all options with keyboard navigation when activated.
pub struct Select {
    /// List of option strings
    options: Vec<Text>,
    /// Currently selected index
    selected: usize,
    /// Whether the dropdown is expanded
    expanded: bool,
    /// Whether this widget currently has keyboard focus
    focused: bool,
    /// Current layout rect
    rect: Rect,
    /// Collapsed/default style
    style: Style,
    /// Selected/focused option style
    selected_style: Style,
}

impl Select {
    /// Creates an empty select.
    pub fn new() -> Self {
        Self {
            options: Vec::new(),
            selected: 0,
            expanded: false,
            focused: false,
            rect: Rect::default(),
            style: Style::default(),
            selected_style: Style::default().bg(crate::style::Color::White).fg(crate::style::Color::Black),
        }
    }

    /// Sets the option list.
    pub fn options(mut self, options: Vec<impl Into<Text>>) -> Self {
        self.options = options.into_iter().map(|o| o.into()).collect();
        self
    }

    /// Sets the collapsed/default style.
    pub fn style(mut self, style: Style) -> Self {
        self.style = style;
        self
    }

    /// Sets the selected option style.
    pub fn selected_style(mut self, style: Style) -> Self {
        self.selected_style = style;
        self
    }

    /// Returns the currently selected index.
    pub fn selected(&self) -> usize {
        self.selected
    }

    /// Returns the text of the currently selected option.
    pub fn selected_text(&self) -> &str {
        self.options.get(self.selected).map(|t| t.first_text()).unwrap_or("")
    }

    /// Sets the selected option.
    pub fn set_selected(&mut self, index: usize, cx: &mut EventCx) {
        if index < self.options.len() {
            self.selected = index;
            cx.invalidate_paint();
        }
    }
}

impl Component for Select {
    fn render(&self, cx: &mut RenderCx) {
        if self.options.is_empty() {
            return;
        }

        // First line: selected option + indicator
        let indicator = if self.expanded { "" } else { "" };
        let display = format!("{} {}", self.selected_text(), indicator);
        if self.focused {
            cx.set_style(self.selected_style.clone());
        } else {
            cx.set_style(self.style.clone());
        }
        cx.line(&display);

        // Expanded: show all options
        if self.expanded {
            for (i, opt) in self.options.iter().enumerate() {
                if i == self.selected {
                    cx.set_style(self.selected_style.clone());
                    cx.text("");
                } else {
                    cx.set_style(self.style.clone());
                    cx.text("  ");
                }
                cx.line(opt.first_text());
            }
        }
    }

    fn measure(&self, _constraint: Constraint, _cx: &mut MeasureCx) -> Size {
        if self.options.is_empty() {
            return Size { width: 0, height: 0 };
        }

        let max_w = self.options.iter().map(|o| o.max_width()).max().unwrap_or(0) + 2;

        let height = if self.expanded {
            1u16.saturating_add(self.options.len() as u16)
        } else {
            1
        };

        Size { width: max_w, height }
    }

    fn event(&mut self, event: &Event, cx: &mut EventCx) {
        match event {
            Event::Focus => {
                self.focused = true;
                cx.invalidate_paint();
                return;
            }
            Event::Blur => {
                self.focused = false;
                self.expanded = false;
                cx.invalidate_layout();
                return;
            }
            _ => {}
        }

        if self.options.is_empty() { return; }

        // Only handle key events during Target phase
        if cx.phase() != crate::event::EventPhase::Target { return; }

        if let Event::Key(key_event) = event {
            match &key_event.key {
                crate::event::Key::Enter | crate::event::Key::Char(' ') => {
                    if self.expanded {
                        self.expanded = false;
                    } else {
                        self.expanded = true;
                    }
                    cx.invalidate_layout();
                    return;
                }
                crate::event::Key::Esc => {
                    if self.expanded {
                        self.expanded = false;
                        cx.invalidate_layout();
                    }
                    return;
                }
                crate::event::Key::Up => {
                    if self.expanded {
                        if self.selected > 0 {
                            self.selected -= 1;
                        } else {
                            self.selected = self.options.len() - 1;
                        }
                        cx.invalidate_paint();
                    }
                    return;
                }
                crate::event::Key::Down => {
                    if self.expanded {
                        if self.selected + 1 < self.options.len() {
                            self.selected += 1;
                        } else {
                            self.selected = 0;
                        }
                        cx.invalidate_paint();
                    }
                    return;
                }
                _ => {}
            }
        }
    }

    fn layout(&mut self, rect: Rect, _cx: &mut crate::component::LayoutCx) {
        self.rect = rect;
    }

    fn focusable(&self) -> bool {
        true
    }

    fn style(&self) -> Style {
        self.style.clone()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::testbuffer::TestBuffer;

    #[test]
    fn test_collapsed() {
        let mut tb = TestBuffer::new(20, 1);
        tb.render(&Select::new().options(vec![Text::from("A")]));
        assert!(tb.buffer.cells[0].symbol.contains("A"));
    }
}