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::{Color, Style};
use crate::text::Text;

/// A checkbox widget with a label and toggleable state.
///
/// Press `Space` or `Enter` to toggle. Renders as `[✓] label` when checked
/// and `[ ] label` when unchecked.
pub struct Checkbox {
    label: Text,
    checked: bool,
    focused: bool,
    rect: Rect,
    style: Style,
    checked_style: Style,
}

impl Checkbox {
    /// Creates a new unchecked checkbox with the given label.
    pub fn new(label: impl Into<Text>) -> Self {
        Self {
            label: label.into(),
            checked: false,
            focused: false,
            rect: Rect::default(),
            style: Style::default(),
            checked_style: Style::default().fg(crate::style::Color::Green),
        }
    }

    /// Builder: sets the initial checked state.
    pub fn checked(mut self) -> Self {
        self.checked = true;
        self
    }

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

    /// Builder: sets the checked-state style.
    pub fn checked_style(mut self, style: Style) -> Self {
        self.checked_style = style;
        self
    }

    /// Returns whether the checkbox is currently checked.
    pub fn is_checked(&self) -> bool {
        self.checked
    }

    /// Sets the checked state.
    pub fn set_checked(&mut self, checked: bool, cx: &mut EventCx) {
        if self.checked != checked {
            self.checked = checked;
            cx.invalidate_paint();
        }
    }

    /// Toggles the checked state.
    pub fn toggle(&mut self, cx: &mut EventCx) {
        self.checked = !self.checked;
        cx.invalidate_paint();
    }
}

impl Component for Checkbox {
    fn render(&self, cx: &mut RenderCx) {
        let mark = if self.checked { "" } else { " " };
        let text = format!("[{}] {}", mark, self.label.first_text());
        if self.focused {
            cx.set_style(self.checked_style.clone());
        } else if self.checked {
            cx.set_style(self.checked_style.clone());
        } else {
            cx.set_style(self.style.clone());
        }
        cx.line(&text);
    }

    fn measure(&self, _constraint: Constraint, _cx: &mut MeasureCx) -> Size {
        Size { width: 5 + self.label.max_width(), height: 1 }
    }

    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; cx.invalidate_paint(); return; }
            _ => {}
        }

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

        if let Event::Key(key_event) = event {
            match &key_event.key {
                crate::event::Key::Char(' ') | crate::event::Key::Enter => {
                    self.toggle(cx);
                }
                _ => {}
            }
        }
    }

    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() }
}

// ── RadioGroup ────────────────────────────────────────────────

/// A radio group widget for mutually exclusive selection.
///
/// Press `↑`/`↓` to move selection, `Space`/`Enter` is not needed since
/// selection changes on navigation. Renders as `(•) option` for selected
/// and `( ) option` for unselected items.
pub struct RadioGroup {
    options: Vec<Text>,
    selected: usize,
    focused: bool,
    rect: Rect,
    style: Style,
    selected_style: Style,
}

impl RadioGroup {
    /// Creates a new radio group with the given options.
    pub fn new(options: Vec<impl Into<Text>>) -> Self {
        let options = options.into_iter().map(|o| o.into()).collect();
        Self {
            options,
            selected: 0,
            focused: false,
            rect: Rect::default(),
            style: Style::default(),
            selected_style: Style::default().fg(crate::style::Color::Green),
        }
    }

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

    /// Builder: sets the selected-item 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() && index != self.selected {
            self.selected = index;
            cx.invalidate_paint();
        }
    }
}

impl Component for RadioGroup {
    fn render(&self, cx: &mut RenderCx) {
        // Fill background when focused for visual distinction
        if self.focused {
            for y in cx.rect.y..cx.rect.y + cx.rect.height {
                for x in cx.rect.x..cx.rect.x + cx.rect.width {
                    if let Some(cell) = cx.buffer.get_mut(x, y) {
                        cell.style.bg = Some(Color::White);
                    }
                }
            }
        }
        for (i, opt) in self.options.iter().enumerate() {
            let (mark, style) = if i == self.selected {
                if self.focused {
                    ("", Style::default().bg(Color::White).fg(Color::Black))
                } else {
                    ("", Style::default().fg(Color::Green))
                }
            } else {
                if self.focused {
                    (" ", Style::default().bg(Color::White).fg(Color::Black))
                } else {
                    (" ", self.style.clone())
                }
            };
            cx.set_style(style);
            cx.line(&format!("({}) {}", mark, opt.first_text()));
        }
    }

    fn measure(&self, _constraint: Constraint, _cx: &mut MeasureCx) -> Size {
        let max_w = self.options.iter().map(|o| 4 + o.max_width()).max().unwrap_or(0);
        Size { width: max_w, height: self.options.len() as u16 }
    }

    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; cx.invalidate_paint(); return; }
            _ => {}
        }

        // Only handle key events during Target phase (when we have focus)
        if cx.phase() != crate::event::EventPhase::Target { return; }
        if self.options.is_empty() { return; }

        if let Event::Key(key_event) = event {
            match &key_event.key {
                crate::event::Key::Up => {
                    self.selected = if self.selected > 0 { self.selected - 1 } else { self.options.len() - 1 };
                    cx.invalidate_paint();
                }
                crate::event::Key::Down => {
                    self.selected = if self.selected + 1 < self.options.len() { self.selected + 1 } else { 0 };
                    cx.invalidate_paint();
                }
                _ => {}
            }
        }
    }

    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_radio_group_renders() {
        let mut tb = TestBuffer::new(20, 2);
        tb.render(&RadioGroup::new(vec![Text::from("A"), Text::from("B")]));
        // First option should render with selected marker
        assert!(tb.buffer.cells[0].symbol.contains('('));
    }

    #[test]
    fn test_radio_group_selection_marker() {
        let mut tb = TestBuffer::new(20, 1);
        tb.render(&RadioGroup::new(vec![Text::from("Option")]));
        // Selected item has • marker
        assert_eq!(&tb.buffer.cells[1].symbol, "");
    }
}

    #[test]
    fn test_checkbox_toggle() {
        // Checkbox should accept toggle
        let cb = Checkbox::new("opt").checked();
        assert!(cb.is_checked());
    }