oo-ide 0.0.4

∞ is a terminal IDE focused on low distraction, high usability.
Documentation
//! Log view — scrollable list of IDE-internal log entries.
//!
//! Layout:
//!   ┌─ Filter ─────────────────────────────────────────────────────────────┐
//!   │ query_                                                               │
//!   ├──────────────────────────────────────────────────────────────────────┤
//!   │ 12:31:05 INFO  oo::app   IDE started                               │
//!   │ 12:31:06 WARN  oo::lsp   Extension failed load                     │
//!   │ 12:31:07 DEBUG oo::lsp   request_completion …                      │
//!   └──────────────────────────────────────────────────────────────────────┘
//!
//! Keybindings:
//!   Tab                     — cycle focus (filter ↔ log list)
//!   Up / Down / PgUp / PgDn — scroll (when log list focused)
//!   Any printable char       — filter input (when filter focused)
//!   Backspace                — delete filter char
//!   Esc                      — dismiss (return to file selector)

use std::cell::Cell;

use log::Level;
use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, List, ListItem, Paragraph},
};

use crate::prelude::*;

use input::{Key, KeyEvent};
use log_buffer::LogEntry;
use operation::{Event, LogViewOp, Operation};
use settings::Settings;
use views::View;
use widgets::focus::FocusRing;
use widgets::focusable::FocusOp;
use widgets::input_field::InputField;

const FOCUS_FILTER: &str = "filter";
const FOCUS_LOG_LIST: &str = "log_list";

// ---------------------------------------------------------------------------
// LogView
// ---------------------------------------------------------------------------

#[derive(Debug)]
pub struct LogView {
    /// All entries captured at the time the view was opened, plus new ones
    /// appended via `handle_operation(LogViewLocal(…))`.
    entries: Vec<LogEntry>,
    filter: InputField,
    focus: FocusRing,
    /// Index of the first *visible* filtered row (scroll offset).
    scroll: Cell<usize>,
    /// If true the view sticks to the bottom as new entries arrive.
    auto_scroll: bool,
}

impl LogView {
    pub fn new(snapshot: Vec<LogEntry>) -> Self {
        Self {
            entries: snapshot,
            filter: InputField::new("Filter"),
            focus: FocusRing::new(vec![FOCUS_FILTER, FOCUS_LOG_LIST]),
            scroll: Cell::new(0),
            auto_scroll: true,
        }
    }

    /// Like [`new`] but pre-populates the filter query.
    pub fn with_filter(snapshot: Vec<LogEntry>, filter_text: impl Into<String>) -> Self {
        let mut v = Self::new(snapshot);
        v.filter.set_text(filter_text.into());
        // Jump to the bottom so the most recent matching entry is visible.
        v.scroll.set(usize::MAX);
        v
    }

    /// Append entries received from the live broadcast channel.
    pub fn push(&mut self, entry: LogEntry) {
        self.entries.push(entry);
        if self.auto_scroll {
            // Keep scroll anchored to bottom — actual clamping happens in render.
            self.scroll.set(usize::MAX);
        }
    }

    fn filtered(&self) -> Vec<&LogEntry> {
        let q = self.filter.text().to_lowercase();
        self.entries
            .iter()
            .filter(|e| {
                q.is_empty()
                    || e.message.to_lowercase().contains(&q)
                    || e.target.to_lowercase().contains(&q)
                    || level_name(e.level).to_lowercase().contains(&q)
            })
            .collect()
    }

}

// ---------------------------------------------------------------------------
// View trait
// ---------------------------------------------------------------------------

impl View for LogView {
    const KIND: crate::views::ViewKind = crate::views::ViewKind::Modal;

    fn save_state(&mut self, _app: &mut crate::app_state::AppState) {}

    fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
        // Global: Esc always dismisses
        

        // Tab cycles focus
        if key.key == Key::Tab {
            return vec![Operation::Focus(FocusOp::Next)];
        }
        if key.key == Key::BackTab {
            return vec![Operation::Focus(FocusOp::Prev)];
        }

        match self.focus.current() {
            FOCUS_FILTER => {
                // Filter-focused: delegate text input
                if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
                    return vec![Operation::LogViewLocal(LogViewOp::FilterInput(field_op))];
                }
                // Arrow up/down still scroll the log even when filter is focused
                match key.key {
                    Key::ArrowUp => vec![Operation::NavigateUp],
                    Key::ArrowDown => vec![Operation::NavigateDown],
                    Key::PageUp => vec![Operation::NavigatePageUp],
                    Key::PageDown => vec![Operation::NavigatePageDown],
                    _ => vec![],
                }
            }
            FOCUS_LOG_LIST => {
                // Log list focused: scroll keys
                match key.key {
                    Key::ArrowUp => vec![Operation::NavigateUp],
                    Key::ArrowDown => vec![Operation::NavigateDown],
                    Key::PageUp => vec![Operation::NavigatePageUp],
                    Key::PageDown => vec![Operation::NavigatePageDown],
                    _ => {
                        // Printable chars go to filter even when log list is focused
                        // (preserves existing behavior: typing always filters)
                        if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
                            return vec![Operation::LogViewLocal(LogViewOp::FilterInput(
                                field_op,
                            ))];
                        }
                        vec![]
                    }
                }
            }
            _ => vec![],
        }
    }

    fn handle_operation(&mut self, op: &Operation, _settings: &Settings) -> Option<Event> {
        match op {
            Operation::Focus(focus_op) => {
                match focus_op {
                    FocusOp::Next => self.focus.focus_next(),
                    FocusOp::Prev => self.focus.focus_prev(),
                    _ => {}
                }
                Some(Event::applied("log_view", op.clone()))
            }
            Operation::LogViewLocal(lv_op) => {
                match lv_op {
                    LogViewOp::FilterInput(field_op) => {
                        self.filter.apply(field_op);
                        self.scroll.set(usize::MAX);
                        self.auto_scroll = true;
                    }
                }
                Some(Event::applied("log_view", op.clone()))
            }

            Operation::NavigateUp => {
                self.auto_scroll = false;
                self.scroll.set(self.scroll.get().saturating_sub(1));
                Some(Event::applied("log_view", op.clone()))
            }
            Operation::NavigateDown => {
                self.scroll.set(self.scroll.get() + 1);
                Some(Event::applied("log_view", op.clone()))
            }
            Operation::NavigatePageUp => {
                self.auto_scroll = false;
                self.scroll.set(self.scroll.get().saturating_sub(10));
                Some(Event::applied("log_view", op.clone()))
            }
            Operation::NavigatePageDown => {
                self.scroll.set(self.scroll.get() + 10);
                Some(Event::applied("log_view", op.clone()))
            }
            _ => None,
        }
    }

    fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Length(1), Constraint::Min(1)])
            .split(area);

        // --- Filter bar ---
        let filter_focused = self.focus.is_focused(FOCUS_FILTER);

        // Render InputField for the filter
        self.filter.render(frame, chunks[0], filter_focused, theme);

        // Auto-scroll indicator (overlay on the filter bar)
        if self.auto_scroll {
            let indicator_x = chunks[0].x + chunks[0].width.saturating_sub(8);
            let indicator_area = Rect {
                x: indicator_x,
                y: chunks[0].y,
                width: 8.min(chunks[0].width),
                height: 1,
            };
            frame.render_widget(
                Paragraph::new(Span::styled(
                    "  [auto]",
                    Style::new().fg(theme.fg_dim()).bg(if filter_focused {
                        theme.bg_active()
                    } else {
                        theme.bg_inactive()
                    }),
                )),
                indicator_area,
            );
        }

        // --- Log list ---
        let list_focused = self.focus.is_focused(FOCUS_LOG_LIST);
        let list_bg = if list_focused {
            theme.bg_active()
        } else {
            theme.bg_inactive()
        };
        let list_area = chunks[1];
        frame.render_widget(Block::default().style(Style::new().bg(list_bg)), list_area);

        let title_area = Rect {
            height: 1,
            ..list_area
        };
        let content_area = Rect {
            y: list_area.y + 1,
            height: list_area.height.saturating_sub(1),
            ..list_area
        };

        // Collect filtered entries
        let filtered: Vec<LogEntry> = self.filtered().into_iter().cloned().collect();
        let total = filtered.len();
        let visible_rows = content_area.height as usize;

        // Clamp scroll
        let max_scroll = total.saturating_sub(visible_rows);
        let scroll = if self.auto_scroll && total > 0 {
            max_scroll
        } else {
            self.scroll.get().min(max_scroll)
        };
        self.scroll.set(scroll);

        // Title row
        frame.render_widget(
            Paragraph::new(Span::styled(
                format!("  {} entries ({} matching)", total, filtered.len()),
                Style::new()
                    .fg(theme.accent())
                    .bg(list_bg)
                    .add_modifier(Modifier::BOLD),
            )),
            title_area,
        );

        let items: Vec<ListItem> = filtered
            .iter()
            .skip(scroll)
            .take(visible_rows)
            .map(|e| {
                let time = format_time(e.time);
                let level_str = format!("{:<5}", level_name(e.level));
                let level_color = level_color(e.level, theme);

                let target_short: String = e
                    .target
                    .split("::")
                    .last()
                    .unwrap_or(&e.target)
                    .chars()
                    .take(12)
                    .collect();

                let line = Line::from(vec![
                    Span::styled(format!("{time} "), Style::default().fg(theme.fg_dim())),
                    Span::styled(
                        format!("{level_str} "),
                        Style::default()
                            .fg(level_color)
                            .add_modifier(Modifier::BOLD),
                    ),
                    Span::styled(
                        format!("{:<12} ", target_short),
                        Style::default().fg(Color::Gray),
                    ),
                    Span::raw(e.message.as_str()),
                ]);
                ListItem::new(line)
            })
            .collect();

        frame.render_widget(List::new(items), content_area);
    }
}

// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------

fn level_name(level: Level) -> &'static str {
    match level {
        Level::Error => "ERROR",
        Level::Warn => "WARN",
        Level::Info => "INFO",
        Level::Debug => "DEBUG",
        Level::Trace => "TRACE",
    }
}

fn level_color(level: Level, theme: &crate::theme::Theme) -> Color {
    match level {
        Level::Error => theme.level_error(),
        Level::Warn => theme.level_warn(),
        Level::Info => theme.level_info(),
        Level::Debug => theme.level_debug(),
        Level::Trace => theme.level_trace(),
    }
}

fn format_time(t: std::time::SystemTime) -> String {
    use std::time::{Duration, UNIX_EPOCH};
    // Compute seconds since epoch, then extract HH:MM:SS (UTC).
    let secs = t
        .duration_since(UNIX_EPOCH)
        .unwrap_or(Duration::ZERO)
        .as_secs();
    let h = (secs / 3600) % 24;
    let m = (secs / 60) % 60;
    let s = secs % 60;
    format!("{h:02}:{m:02}:{s:02}")
}