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::node::Node;
use crate::render::RenderCx;
use crate::style::{Border, Color, Style};

/// A dialog wrapper that adds Esc/Enter key bindings and a footer hint.
///
/// Renders the child inside a [`Border::Rounded`] block and shows available
/// keyboard actions in a dimmed footer line. Check [`Dialog::confirmed`] or
/// [`Dialog::cancelled`] after interaction to respond to user choices.
pub struct Dialog {
    inner: Node,
    cancelled: bool,
    confirmed: bool,
    rect: Rect,
    style: Style,
    border: Border,
}

impl Dialog {
    /// Creates a dialog wrapping the given child component.
    pub fn new(child: impl Component + 'static) -> Self {
        Self {
            inner: Node::new(child),
            cancelled: false,
            confirmed: false,
            rect: Rect::default(),
            style: Style::default(),
            border: Border::Rounded,
        }
    }

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

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

    /// Returns true if the dialog was cancelled (Esc pressed).
    pub fn cancelled(&self) -> bool { self.cancelled }

    /// Returns true if the dialog was confirmed (Enter pressed).
    pub fn confirmed(&self) -> bool { self.confirmed }

    /// Resets the cancelled/confirmed state.
    pub fn reset(&mut self, cx: &mut EventCx) {
        self.cancelled = false;
        self.confirmed = false;
        cx.invalidate_paint();
    }
}

impl Component for Dialog {
    fn render(&self, cx: &mut RenderCx) {
        // Use our own stored rect
        let r = self.rect;

        // Draw border
        cx.buffer.draw_border(r, self.border, &cx.style);

        // Render inner content with padding
        let inner_rect = r.inner(crate::geom::Insets::all(2));
        let saved = self.inner.rect();
        self.inner.set_rect(inner_rect);
        self.inner.render(cx.buffer, cx.focused_id);
        self.inner.set_rect(saved);

        // Footer hint
        let footer_y = r.y.saturating_add(r.height.saturating_sub(1));
        let footer_style = Style::default().fg(Color::Gray);
        cx.buffer.write_text(
            crate::geom::Pos { x: r.x.saturating_add(2), y: footer_y },
            r,
            "Enter: confirm  Esc: cancel",
            &footer_style,
        );
        // Restore cursor position — Dialog doesn't advance the cursor
        cx.cursor.x = r.x;
        cx.cursor.y = r.y;
    }

    fn measure(&self, constraint: Constraint, _cx: &mut MeasureCx) -> Size {
        let child_size = self.inner.measure(constraint);
        Size {
            width: child_size.width.saturating_add(4),   // border + padding
            height: child_size.height.saturating_add(4), // border + padding + footer
        }
    }

    fn event(&mut self, event: &Event, cx: &mut EventCx) {
        match event {
            Event::Focus | Event::Blur | Event::Tick => return,
            _ => {}
        }

        // Only handle confirm/cancel during Target or Bubble phase
        if cx.phase() != crate::event::EventPhase::Capture {
            if let Event::Key(key_event) = event {
                match &key_event.key {
                    crate::event::Key::Esc => {
                        self.cancelled = true;
                        self.confirmed = false;
                        cx.invalidate_paint();
                        return;
                    }
                    crate::event::Key::Enter => {
                        self.confirmed = true;
                        self.cancelled = false;
                        cx.invalidate_paint();
                        return;
                    }
                    _ => {}
                }
            }
        }

        // Forward other events to child in Capture phase only
        if cx.phase() == crate::event::EventPhase::Capture {
            let mut child_cx = EventCx::with_task_sender(
                &mut self.inner.dirty, cx.global_dirty, cx.quit,
                cx.phase, cx.propagation_stopped, cx.task_sender.clone(),
            );
            self.inner.component.event(event, &mut child_cx);
        }
    }

    fn layout(&mut self, rect: Rect, _cx: &mut crate::component::LayoutCx) {
        self.rect = rect;
        let inner = rect.inner(crate::geom::Insets::all(2));
        self.inner.layout(inner);
    }

    fn for_each_child(&self, f: &mut dyn FnMut(&Node)) { f(&self.inner); }
    fn for_each_child_mut(&mut self, f: &mut dyn FnMut(&mut Node)) { f(&mut self.inner); }
    fn focusable(&self) -> bool { true }
    fn style(&self) -> Style { self.style.clone() }
}

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

    #[test]
    fn test_dialog_renders() {
        let mut tb = TestBuffer::new(20, 5);
        tb.render(&Dialog::new(Label::new("test")).border(Border::Rounded));
        // Should have border corner character
        assert!(tb.buffer.cells.iter().any(|c| c.symbol != " "));
    }
}