logana 0.6.0

Turn any log source — files, compressed archives, Docker, or OTel streams — into structured data. Filter by pattern, field, or date range; annotate lines; bookmark findings; and export to Markdown, Jira, or AI assistants via the built-in MCP server.
Documentation
use ratatui::{
    prelude::*,
    widgets::{Paragraph, Wrap},
};

use crate::theme::Theme;

pub struct InputBar<'a> {
    pub query: &'a str,
    pub cursor_pos: usize,
    pub forward: bool,
    pub is_active: bool,
    pub total_matches: usize,
    pub current_occurrence: usize,
    pub progress: Option<(String, usize)>,
    pub theme: &'a Theme,
}

impl<'a> InputBar<'a> {
    pub fn cursor_position(&self, input_area: Rect) -> Option<(u16, u16)> {
        if !self.is_active {
            return None;
        }
        let cursor_x = input_area.x + 1 + self.cursor_pos as u16;
        if cursor_x < input_area.x + input_area.width {
            Some((cursor_x, input_area.y))
        } else {
            None
        }
    }

    fn hint_text(&self) -> String {
        if !self.query.is_empty() {
            if self.is_active {
                format!("  {} matches", self.total_matches)
            } else if self.total_matches == 0 {
                "  no matches".to_string()
            } else {
                format!(
                    "  match {} / {}",
                    self.current_occurrence, self.total_matches
                )
            }
        } else {
            "  Type pattern and press Enter to search".to_string()
        }
    }
}

impl<'a> Widget for InputBar<'a> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Length(1), Constraint::Min(1)])
            .split(area);

        let prefix = if self.forward { "/" } else { "?" };
        let search_line = Paragraph::new(format!("{}{}", prefix, self.query))
            .style(
                Style::default()
                    .fg(self.theme.cursor_fg)
                    .bg(self.theme.cursor_bg),
            )
            .wrap(Wrap { trim: false });
        search_line.render(chunks[0], buf);

        let hint_text = self.hint_text();
        let hint = Paragraph::new(hint_text)
            .style(Style::default().fg(self.theme.text).bg(self.theme.root_bg));
        hint.render(chunks[1], buf);

        if let Some((bar_str, pct)) = self.progress {
            let text = format!(" {} {}% ", bar_str, pct);
            let text_width = text.chars().count() as u16;
            let x = chunks[1].x + (chunks[1].width.saturating_sub(text_width)) / 2;
            let w = chunks[1].width.min(text_width);
            let progress_rect = Rect::new(x, chunks[1].y, w, 1);
            Paragraph::new(text)
                .style(
                    Style::default()
                        .fg(self.theme.border)
                        .bg(self.theme.root_bg),
                )
                .render(progress_rect, buf);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::theme::Theme;
    use ratatui::{Terminal, backend::TestBackend};

    fn make_bar<'a>(query: &'a str, is_active: bool, theme: &'a Theme) -> InputBar<'a> {
        InputBar {
            query,
            cursor_pos: query.len(),
            forward: true,
            is_active,
            total_matches: 5,
            current_occurrence: 2,
            progress: None,
            theme,
        }
    }

    #[test]
    fn test_input_bar_renders_active() {
        let theme = Theme::default();
        let bar = InputBar {
            query: "hello",
            cursor_pos: 5,
            forward: true,
            is_active: true,
            total_matches: 3,
            current_occurrence: 1,
            progress: None,
            theme: &theme,
        };
        let mut terminal = Terminal::new(TestBackend::new(80, 2)).unwrap();
        terminal.draw(|f| f.render_widget(bar, f.area())).unwrap();
    }

    #[test]
    fn test_input_bar_renders_inactive() {
        let theme = Theme::default();
        let bar = InputBar {
            query: "world",
            cursor_pos: 5,
            forward: false,
            is_active: false,
            total_matches: 2,
            current_occurrence: 1,
            progress: None,
            theme: &theme,
        };
        let mut terminal = Terminal::new(TestBackend::new(80, 2)).unwrap();
        terminal.draw(|f| f.render_widget(bar, f.area())).unwrap();
    }

    #[test]
    fn test_cursor_position_active() {
        let theme = Theme::default();
        let bar = InputBar {
            query: "abc",
            cursor_pos: 3,
            forward: true,
            is_active: true,
            total_matches: 1,
            current_occurrence: 1,
            progress: None,
            theme: &theme,
        };
        let area = Rect::new(0, 5, 80, 1);
        let pos = bar.cursor_position(area);
        assert_eq!(pos, Some((4, 5)));
    }

    #[test]
    fn test_cursor_position_inactive() {
        let theme = Theme::default();
        let bar = InputBar {
            query: "abc",
            cursor_pos: 3,
            forward: true,
            is_active: false,
            total_matches: 1,
            current_occurrence: 1,
            progress: None,
            theme: &theme,
        };
        let area = Rect::new(0, 5, 80, 1);
        assert_eq!(bar.cursor_position(area), None);
    }

    #[test]
    fn test_hint_text_active_with_matches() {
        let theme = Theme::default();
        let bar = InputBar {
            query: "x",
            cursor_pos: 1,
            forward: true,
            is_active: true,
            total_matches: 7,
            current_occurrence: 1,
            progress: None,
            theme: &theme,
        };
        assert_eq!(bar.hint_text(), "  7 matches");
    }

    #[test]
    fn test_hint_text_inactive_no_matches() {
        let theme = Theme::default();
        let bar = InputBar {
            query: "x",
            cursor_pos: 1,
            forward: true,
            is_active: false,
            total_matches: 0,
            current_occurrence: 0,
            progress: None,
            theme: &theme,
        };
        assert_eq!(bar.hint_text(), "  no matches");
    }

    #[test]
    fn test_hint_text_inactive_with_matches() {
        let theme = Theme::default();
        let bar = InputBar {
            query: "x",
            cursor_pos: 1,
            forward: true,
            is_active: false,
            total_matches: 10,
            current_occurrence: 3,
            progress: None,
            theme: &theme,
        };
        assert_eq!(bar.hint_text(), "  match 3 / 10");
    }

    #[test]
    fn test_hint_text_empty_query() {
        let theme = Theme::default();
        let bar = make_bar("", true, &theme);
        assert!(bar.hint_text().contains("Type pattern"));
    }
}