lv-tui 0.2.0

A reactive TUI framework for Rust, inspired by Textual and React
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;

/// 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: String,
    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<String>) -> 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);
        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 {
        let w: u16 = self.label.chars().map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16).sum();
        Size { width: 5 + w, 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<String>,
    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<String>) -> Self {
        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(|s| s.as_str()).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) {
        for (i, opt) in self.options.iter().enumerate() {
            let (mark, style) = if i == self.selected {
                if self.focused {
                    ("", self.selected_style.clone())
                } else {
                    ("", self.selected_style.clone())
                }
            } else {
                (" ", self.style.clone())
            };
            cx.set_style(style);
            cx.line(&format!("({}) {}", mark, opt));
        }
    }

    fn measure(&self, _constraint: Constraint, _cx: &mut MeasureCx) -> Size {
        let max_w: u16 = self.options.iter()
            .map(|o| 4 + o.chars().map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16).sum::<u16>())
            .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() }
}