tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! Text input component with cursor rendering.
//!
//! Provides a single-line text input field with:
//! - Focus-aware border styling
//! - Cursor position indicator
//! - Consistent styling with other components

use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Style},
    text::{Line, Span},
    widgets::Widget,
};

use crate::tui::theme;

/// A single-line text input widget with cursor support.
///
/// Renders text content with a visible cursor position when focused.
/// Uses theme styling for consistent appearance.
///
/// # Examples
/// ```ignore
/// let input = TextInput::new("feature/", 8, "New Branch")
///     .focused(self.section == PopupSection::BranchInput);
/// frame.render_widget(input, area);
/// ```
pub struct TextInput<'a> {
    content: &'a str,
    cursor: usize,
    title: &'a str,
    focused: bool,
}

impl<'a> TextInput<'a> {
    /// Create a new text input widget.
    ///
    /// # Arguments
    /// - `content`: The current text content
    /// - `cursor`: Cursor position (character index)
    /// - `title`: Block title (displayed in border)
    #[must_use]
    pub fn new(content: &'a str, cursor: usize, title: &'a str) -> Self {
        Self {
            content,
            cursor,
            title,
            focused: false,
        }
    }

    /// Set whether this input is focused.
    ///
    /// Focus state affects border color and cursor visibility.
    #[must_use]
    pub fn focused(mut self, focused: bool) -> Self {
        self.focused = focused;
        self
    }
}

impl Widget for TextInput<'_> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let block = theme::block(
            self.title,
            if self.focused {
                theme::BlockVariant::Focused
            } else {
                theme::BlockVariant::Unfocused
            },
        );
        let inner = block.inner(area);
        block.render(area, buf);

        if inner.height == 0 || inner.width < 2 {
            return;
        }

        // Render input text
        let input_style = if self.focused {
            Style::default().fg(Color::White)
        } else {
            Style::default().fg(Color::Gray)
        };
        let line = Line::from(Span::styled(self.content, input_style));
        buf.set_line(inner.x + 1, inner.y, &line, inner.width.saturating_sub(2));

        // Render cursor if focused
        if self.focused && inner.width > 1 {
            #[allow(clippy::cast_possible_truncation)]
            let cursor_offset = self.cursor as u16;
            let cursor_x = inner.x + 1 + cursor_offset;
            if cursor_x < inner.x + inner.width.saturating_sub(1) {
                buf[(cursor_x, inner.y)]
                    .set_style(Style::default().bg(Color::White).fg(Color::Black));
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tui::test_utils::{buffer_to_text, render_to_snapshot};

    #[test]
    fn renders_empty_input() {
        let input = TextInput::new("", 0, "Input").focused(true);
        let text = buffer_to_text_widget(input, 30, 3);
        assert!(text.contains("Input"), "Text:\n{text}");
    }

    #[test]
    fn renders_content() {
        let input = TextInput::new("hello world", 0, "Text").focused(true);
        let text = buffer_to_text_widget(input, 30, 3);
        assert!(text.contains("hello world"), "Text:\n{text}");
    }

    #[test]
    fn focused_input_has_cyan_border() {
        let input = TextInput::new("test", 0, "Focused").focused(true);
        let snapshot = render_to_snapshot(input, 30, 3);
        // Cyan border characters should have [C] prefix
        assert!(
            snapshot.contains("[C]"),
            "Focused should have cyan border:\n{snapshot}"
        );
    }

    #[test]
    fn unfocused_input_has_gray_border() {
        let input = TextInput::new("test", 0, "Unfocused").focused(false);
        let snapshot = render_to_snapshot(input, 30, 3);
        // Gray border characters should have [Gy] prefix
        assert!(
            snapshot.contains("[Gy]"),
            "Unfocused should have gray border:\n{snapshot}"
        );
    }

    #[test]
    fn cursor_renders_at_correct_position() {
        let input = TextInput::new("abc", 1, "Cursor").focused(true);
        let snapshot = render_to_snapshot(input, 30, 3);
        // Cursor at position 1 should show inverted 'b' (bg:White, fg:Black)
        // This is hard to verify in text, but we can check the input renders
        assert!(
            snapshot.contains('a'),
            "Input should contain 'a':\n{snapshot}"
        );
    }

    #[test]
    fn cursor_not_visible_when_unfocused() {
        let input = TextInput::new("abc", 1, "NoCursor").focused(false);
        let snapshot = render_to_snapshot(input, 30, 3);
        // When unfocused, text should be gray without cursor highlight
        assert!(
            snapshot.contains("[Gy]a"),
            "Unfocused text should be gray:\n{snapshot}"
        );
    }

    /// Helper to render widget to plain text
    fn buffer_to_text_widget<W: Widget>(widget: W, width: u16, height: u16) -> String {
        let area = Rect::new(0, 0, width, height);
        let mut buf = Buffer::empty(area);
        widget.render(area, &mut buf);
        buffer_to_text(&buf)
    }
}