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::{Position, Rect};

use super::TabState;

pub struct InputHandler {
    pub log_panel_area: Rect,
    pub sidebar_area: Option<Rect>,
    pub last_click: Option<(std::time::Instant, u16, u16)>,
    pub scrollbar_dragging: bool,
}

impl InputHandler {
    pub fn hit_test_scrollbar(&self, col: u16, row: u16, tab: &TabState) -> Option<usize> {
        let area = self.log_panel_area;
        if row < area.y || row >= area.y + area.height {
            return None;
        }
        let show_borders = tab.display.show_borders;
        let scrollbar_col = if show_borders {
            area.right().saturating_sub(2)
        } else {
            area.right().saturating_sub(1)
        };
        if col != scrollbar_col {
            return None;
        }
        let total_visible = tab.filter.visible_indices.len();
        let max_scroll = total_visible.saturating_sub(tab.scroll.visible_height);
        let bar_height = area.height as usize;
        let pos = (row - area.y) as usize;
        Some(((pos * max_scroll) / bar_height.max(1)).min(max_scroll))
    }

    pub fn hit_test_sidebar(&self, col: u16, row: u16, tab: &TabState) -> Option<usize> {
        let area = self.sidebar_area?;
        if !area.contains(Position::new(col, row)) {
            return None;
        }
        let item_row = row.saturating_sub(area.y + 1) as usize;
        let filters = tab.log_manager.get_filters();
        let num_filters = filters.len();
        if num_filters == 0 {
            return None;
        }
        let inner_width = if tab.display.show_borders {
            area.width.saturating_sub(2) as usize
        } else {
            area.width.saturating_sub(1) as usize
        };
        let mut accumulated = 0usize;
        for (idx, filter) in filters.iter().enumerate() {
            let text = super::widgets::sidebar::filter_row_display_text(
                filter,
                idx,
                0,
                &tab.filter.match_counts,
            );
            let rc = super::field_layout::line_row_count(text.as_bytes(), inner_width);
            if accumulated + rc > item_row {
                return Some(idx);
            }
            accumulated += rc;
        }
        Some(num_filters.saturating_sub(1))
    }

    pub fn hit_test_log_panel(&self, col: u16, row: u16, tab: &TabState) -> Option<usize> {
        let area = self.log_panel_area;
        let show_borders = tab.display.show_borders;
        let show_tab_bar = true;
        let x_off: u16 = if show_borders { 1 } else { 0 };
        let y_off: u16 = if show_borders && !show_tab_bar { 1 } else { 0 };
        let height_sub: u16 = if show_borders {
            if show_tab_bar { 1 } else { 2 }
        } else {
            0
        };
        let inner = Rect {
            x: area.x + x_off,
            y: area.y + y_off,
            width: area.width.saturating_sub(x_off * 2 + 1),
            height: area.height.saturating_sub(height_sub),
        };
        if !inner.contains(Position::new(col, row)) {
            return None;
        }
        let visual_row = (row - inner.y) as usize;
        if !tab.display.wrap {
            let visible_idx = tab.scroll.viewport_offset + visual_row;
            return if visible_idx < tab.filter.visible_indices.len() {
                Some(visible_idx)
            } else {
                None
            };
        }
        let inner_width = tab.scroll.visible_width;
        let parser = tab.display.format.as_deref();
        let field_layout = &tab.display.field_layout;
        let hidden_fields = &tab.display.hidden_fields;
        let show_keys = tab.display.show_keys;
        let visible_count = tab.filter.visible_indices.len();
        let mut accumulated = 0usize;
        let mut idx = tab.scroll.viewport_offset;
        while idx < visible_count {
            let line_bytes = tab
                .file_reader
                .get_line(tab.filter.visible_indices.get(idx));
            let rc = super::field_layout::effective_row_count(
                line_bytes,
                inner_width,
                parser,
                field_layout,
                hidden_fields,
                show_keys,
            );
            if accumulated + rc > visual_row {
                return Some(idx);
            }
            accumulated += rc;
            idx += 1;
        }
        None
    }

    pub fn col_to_char_offset(&self, col: u16, tab: &TabState) -> usize {
        let area = self.log_panel_area;
        let x_off: u16 = if tab.display.show_borders { 1 } else { 0 };
        let total_lines = tab.file_reader.line_count();
        let ln_prefix: u16 = if tab.display.show_line_numbers {
            (total_lines.max(1).to_string().len() + 2) as u16
        } else {
            0
        };
        col.saturating_sub(area.x + x_off + ln_prefix) as usize + tab.scroll.horizontal_scroll
    }
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use ratatui::prelude::Rect;

    use crate::db::LogManager;
    use crate::ingestion::FileReader;

    use super::*;

    async fn fixture(
        visible_lines: usize,
        visible_height: usize,
        log_area: Rect,
        sidebar_area: Option<Rect>,
    ) -> (InputHandler, TabState) {
        let db = Arc::new(crate::db::Database::in_memory().await.unwrap());
        let log_manager = LogManager::new(db, None).await;
        let data: Vec<u8> = (0..visible_lines)
            .map(|i| format!("line {}\n", i))
            .collect::<String>()
            .into_bytes();
        let file_reader = FileReader::from_bytes(data);
        let mut tab = TabState::new(file_reader, log_manager, "test".to_string());
        tab.filter.visible_indices = crate::ui::VisibleLines::All(visible_lines);
        tab.display.show_borders = false;
        tab.scroll.visible_height = visible_height;
        let handler = InputHandler {
            log_panel_area: log_area,
            sidebar_area,
            last_click: None,
            scrollbar_dragging: false,
        };
        (handler, tab)
    }

    #[tokio::test]
    async fn test_hit_test_scrollbar_correct_column_no_borders() {
        let area = Rect {
            x: 0,
            y: 0,
            width: 80,
            height: 40,
        };
        let (h, tab) = fixture(200, 40, area, None).await;
        assert!(h.hit_test_scrollbar(79, 10, &tab).is_some());
        assert!(h.hit_test_scrollbar(78, 10, &tab).is_none());
    }

    #[tokio::test]
    async fn test_hit_test_scrollbar_out_of_row_range() {
        let area = Rect {
            x: 0,
            y: 5,
            width: 80,
            height: 20,
        };
        let (h, tab) = fixture(200, 20, area, None).await;
        assert!(h.hit_test_scrollbar(79, 4, &tab).is_none());
        assert!(h.hit_test_scrollbar(79, 25, &tab).is_none());
        assert!(h.hit_test_scrollbar(79, 5, &tab).is_some());
        assert!(h.hit_test_scrollbar(79, 24, &tab).is_some());
    }

    #[tokio::test]
    async fn test_hit_test_scrollbar_proportional_position() {
        let area = Rect {
            x: 0,
            y: 0,
            width: 80,
            height: 10,
        };
        let (h, tab) = fixture(100, 10, area, None).await;
        assert_eq!(h.hit_test_scrollbar(79, 5, &tab).unwrap(), 45);
        assert_eq!(h.hit_test_scrollbar(79, 0, &tab).unwrap(), 0);
        assert!(h.hit_test_scrollbar(79, 9, &tab).unwrap() <= 90);
    }

    #[tokio::test]
    async fn test_hit_test_sidebar_outside_returns_none() {
        let log_area = Rect {
            x: 0,
            y: 0,
            width: 60,
            height: 40,
        };
        let sidebar_area = Rect {
            x: 60,
            y: 0,
            width: 20,
            height: 40,
        };
        let (h, tab) = fixture(10, 10, log_area, Some(sidebar_area)).await;
        assert!(h.hit_test_sidebar(59, 0, &tab).is_none());
        assert!(h.hit_test_sidebar(80, 0, &tab).is_none());
    }

    #[tokio::test]
    async fn test_hit_test_sidebar_no_sidebar_returns_none() {
        let log_area = Rect {
            x: 0,
            y: 0,
            width: 80,
            height: 40,
        };
        let (h, tab) = fixture(10, 10, log_area, None).await;
        assert!(h.hit_test_sidebar(70, 5, &tab).is_none());
    }

    #[tokio::test]
    async fn test_hit_test_log_panel_maps_row_to_visible_idx() {
        let area = Rect {
            x: 0,
            y: 0,
            width: 80,
            height: 20,
        };
        let (h, mut tab) = fixture(50, 20, area, None).await;
        tab.scroll.viewport_offset = 5;
        assert_eq!(h.hit_test_log_panel(10, 3, &tab), Some(8));
        assert_eq!(h.hit_test_log_panel(10, 0, &tab), Some(5));
    }

    #[tokio::test]
    async fn test_hit_test_log_panel_with_borders_no_top_border() {
        let area = Rect {
            x: 0,
            y: 0,
            width: 80,
            height: 20,
        };
        let (h, mut tab) = fixture(50, 20, area, None).await;
        tab.display.show_borders = true;
        tab.scroll.viewport_offset = 5;
        assert_eq!(h.hit_test_log_panel(10, 0, &tab), Some(5));
        assert_eq!(h.hit_test_log_panel(10, 3, &tab), Some(8));
        assert_eq!(h.hit_test_log_panel(10, 19, &tab), None);
    }

    #[tokio::test]
    async fn test_hit_test_log_panel_outside_returns_none() {
        let area = Rect {
            x: 0,
            y: 0,
            width: 80,
            height: 20,
        };
        let (h, tab) = fixture(50, 20, area, None).await;
        assert!(h.hit_test_log_panel(79, 5, &tab).is_none());
        assert!(h.hit_test_log_panel(10, 20, &tab).is_none());
    }

    #[tokio::test]
    async fn test_hit_test_log_panel_beyond_visible_lines_returns_none() {
        let area = Rect {
            x: 0,
            y: 0,
            width: 80,
            height: 20,
        };
        let (h, mut tab) = fixture(5, 20, area, None).await;
        tab.scroll.scroll_offset = 0;
        assert!(h.hit_test_log_panel(10, 5, &tab).is_none());
        assert!(h.hit_test_log_panel(10, 4, &tab).is_some());
    }
}