lv-tui 0.4.0

A reactive TUI framework for Rust
Documentation
use crate::component::{Component, EventCx, LayoutCx, 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 single-line text input widget.
///
/// Supports cursor movement (Home/End/Left/Right), Backspace/Delete,
/// placeholder text, focus styling, and an optional submit callback.
/// Use [`Input::text`] to retrieve the entered value.
pub struct Input {
    text: String,
    /// Byte-offset cursor position.
    cursor_byte: usize,
    focused: bool,
    placeholder: Text,
    style: Style,
    focus_style: Style,
    /// Callback invoked on Enter (takes the current text, returns nothing).
    on_submit: Option<Box<dyn FnMut(&str)>>,
}

impl Input {
    /// Creates an empty input field.
    pub fn new() -> Self {
        Self {
            text: String::new(),
            cursor_byte: 0,
            focused: false,
            placeholder: Text::from(""),
            style: Style::default(),
            focus_style: Style::default()
                .bg(crate::style::Color::White)
                .fg(crate::style::Color::Black),
            on_submit: None,
        }
    }

    pub fn placeholder(mut self, text: impl Into<Text>) -> Self {
        self.placeholder = text.into();
        self
    }

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

    pub fn focus_style(mut self, style: Style) -> Self {
        self.focus_style = style;
        self
    }

    /// Sets a callback invoked when Enter is pressed.
    /// Receives the current text as a `&str`.
    pub fn on_submit(mut self, f: impl FnMut(&str) + 'static) -> Self {
        self.on_submit = Some(Box::new(f));
        self
    }

    pub fn text(&self) -> &str {
        &self.text
    }

    fn clamp_cursor(&mut self) {
        if self.cursor_byte > self.text.len() {
            self.cursor_byte = self.text.len();
        }
    }

    fn cursor_left(&mut self) {
        if self.cursor_byte == 0 {
            return;
        }
        // 找到前一个 char 的边界
        if let Some((i, _)) = self.text.char_indices().rev().find(|&(i, _)| i < self.cursor_byte) {
            self.cursor_byte = i;
        } else {
            self.cursor_byte = 0;
        }
    }

    fn cursor_right(&mut self) {
        // 找到下一个 char 边界
        if let Some((i, _)) = self
            .text
            .char_indices()
            .find(|&(i, _)| i > self.cursor_byte)
        {
            self.cursor_byte = i;
        } else {
            self.cursor_byte = self.text.len();
        }
    }

    /// cursor_byte 之前有多少个 char(用于渲染时高亮第几个字符)
    fn cursor_char_index(&self) -> usize {
        self.text[..self.cursor_byte].chars().count()
    }
}

impl Component for Input {
    fn render(&self, cx: &mut RenderCx) {
        let placeholder_mode = self.text.is_empty() && !self.focused;
        let display_str: String = if placeholder_mode {
            self.placeholder.first_text().to_string()
        } else {
            self.text.clone()
        };

        let base_style = if placeholder_mode {
            Style::default().fg(crate::style::Color::Gray)
        } else {
            Style::default()
        };

        let cursor_style = Style::default()
            .bg(crate::style::Color::White)
            .fg(crate::style::Color::Black);

        let cursor_char = if placeholder_mode {
            0
        } else {
            self.cursor_char_index()
        };
        for (i, ch) in display_str.chars().enumerate() {
            if i == cursor_char && self.focused {
                cx.set_style(cursor_style.clone());
            } else {
                cx.set_style(base_style.clone());
            }
            cx.text(ch.to_string());
        }

        if self.cursor_byte >= self.text.len() && self.focused {
            cx.set_style(cursor_style);
            cx.text(" ");
        }
    }

    fn measure(&self, _constraint: Constraint, _cx: &mut MeasureCx) -> Size {
        let display_str: String = if self.text.is_empty() {
            self.placeholder.first_text().to_string()
        } else {
            self.text.clone()
        };
        let width: u16 = display_str
            .chars()
            .map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16)
            .sum();
        Size {
            width: (width + 1),
            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;
            }
            _ => {}
        }

        if cx.phase() != crate::event::EventPhase::Target {
            return;
        }

        if let Event::Key(key_event) = event {
            // Ctrl+C: quit application
            if key_event.key == crate::event::Key::Char('c') && key_event.modifiers.ctrl {
                cx.quit();
                return;
            }
            // Ctrl+D: delete forward, or do nothing on empty
            if key_event.key == crate::event::Key::Char('d') && key_event.modifiers.ctrl {
                if self.cursor_byte < self.text.len() {
                    let end = self.text[self.cursor_byte..]
                        .chars()
                        .next()
                        .map(|c| self.cursor_byte + c.len_utf8())
                        .unwrap_or(self.cursor_byte);
                    self.text.drain(self.cursor_byte..end);
                    cx.invalidate_paint();
                }
                return;
            }
            if key_event.modifiers.ctrl || key_event.modifiers.alt {
                return;
            }

            self.clamp_cursor();

            match &key_event.key {
                crate::event::Key::Enter => {
                    if let Some(ref mut f) = self.on_submit {
                        f(&self.text);
                    }
                }
                crate::event::Key::Char(ch) => {
                    self.text.insert(self.cursor_byte, *ch);
                    self.cursor_byte += ch.len_utf8();
                    cx.invalidate_paint();
                }
                crate::event::Key::Backspace => {
                    if self.cursor_byte > 0 {
                        let old = self.cursor_byte;
                        self.cursor_left();
                        self.text.drain(self.cursor_byte..old);
                        cx.invalidate_paint();
                    }
                }
                crate::event::Key::Delete => {
                    if self.cursor_byte < self.text.len() {
                        let end = self.text[self.cursor_byte..]
                            .chars()
                            .next()
                            .map(|c| self.cursor_byte + c.len_utf8())
                            .unwrap_or(self.cursor_byte);
                        self.text.drain(self.cursor_byte..end);
                        cx.invalidate_paint();
                    }
                }
                crate::event::Key::Left => {
                    let old = self.cursor_byte;
                    self.cursor_left();
                    if self.cursor_byte != old {
                        cx.invalidate_paint();
                    }
                }
                crate::event::Key::Right => {
                    let old = self.cursor_byte;
                    self.cursor_right();
                    if self.cursor_byte != old {
                        cx.invalidate_paint();
                    }
                }
                crate::event::Key::Home => {
                    if self.cursor_byte != 0 {
                        self.cursor_byte = 0;
                        cx.invalidate_paint();
                    }
                }
                crate::event::Key::End => {
                    if self.cursor_byte != self.text.len() {
                        self.cursor_byte = self.text.len();
                        cx.invalidate_paint();
                    }
                }
                _ => {}
            }
        }
    }

    fn layout(&mut self, _rect: Rect, _cx: &mut LayoutCx) {}

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

#[cfg(test)]
mod tests {
    // Input tests go here
}