dictx 0.1.2

A fast, colorful terminal dictionary with offline indexes and optional AI explanations.
use crate::ai;
use crate::config::AppConfig;
use crate::output::{self, print_entries_page, print_load_more_hint, RenderedEntries};
use anyhow::Result;
use crossterm::event::{
    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, MouseButton, MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use dictx_search::SearchResult;
use std::io::{self, IsTerminal, Write};
use std::time::{Duration, Instant};
use unicode_width::UnicodeWidthStr;

const PAGE_SIZE: usize = 3;

pub fn run(
    config: &AppConfig,
    result: &SearchResult,
    rendered: RenderedEntries,
    color: bool,
) -> Result<()> {
    if result.entries.is_empty() || !io::stdout().is_terminal() {
        return Ok(());
    }

    let mut shown = rendered.shown;
    let mut visible_words = rendered.visible_words;
    let mut action_bar = print_action_bar(&visible_words, shown, result.entries.len())?;
    let mut terminal = InteractiveTerminal::start()?;

    loop {
        if !event::poll(Duration::from_millis(500))? {
            continue;
        }

        let action = match event::read()? {
            Event::Key(key) => {
                action_from_key(key.code, visible_words.len(), shown, result.entries.len())
            }
            Event::Mouse(mouse)
                if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) =>
            {
                action_bar.action_at(mouse.column, mouse.row)
            }
            _ => None,
        };

        match action {
            Some(Action::Quit) => break,
            Some(Action::More) => {
                terminal.suspend()?;
                let page = print_entries_page(result, shown, PAGE_SIZE, color, false, true);
                shown = page.shown;
                let can_load_more = page.can_load_more();
                visible_words = page.visible_words;
                if can_load_more {
                    print_load_more_hint(shown, result.entries.len(), color, true);
                }
                action_bar = print_action_bar(&visible_words, shown, result.entries.len())?;
                terminal.resume()?;
            }
            Some(Action::Ai(index)) => {
                if let Some(word) = visible_words.get(index) {
                    terminal.suspend()?;
                    println!();
                    println!("正在请求 AI 详解: {word}");
                    match ai::explain(config, word, None, None) {
                        Ok(content) => output::print_ai_answer(word, &content, color),
                        Err(err) => eprintln!("AI 查询失败: {err}"),
                    }
                    action_bar = print_action_bar(&visible_words, shown, result.entries.len())?;
                    terminal.resume()?;
                }
            }
            None => {}
        }
    }

    terminal.suspend()?;
    Ok(())
}

#[derive(Debug, Clone)]
enum Action {
    Ai(usize),
    More,
    Quit,
}

#[derive(Debug)]
struct ActionBar {
    row: Option<u16>,
    zones: Vec<ActionZone>,
}

impl ActionBar {
    fn action_at(&self, column: u16, row: u16) -> Option<Action> {
        if self.row.is_some_and(|expected| row != expected) {
            return None;
        }
        self.zones
            .iter()
            .find(|zone| column >= zone.start && column <= zone.end)
            .map(|zone| zone.action.clone())
    }
}

#[derive(Debug)]
struct ActionZone {
    start: u16,
    end: u16,
    action: Action,
}

fn action_from_key(
    code: KeyCode,
    visible_count: usize,
    shown: usize,
    available: usize,
) -> Option<Action> {
    match code {
        KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => Some(Action::Quit),
        KeyCode::Char('n') | KeyCode::Char('N') if shown < available => Some(Action::More),
        KeyCode::Char(ch) if ch.is_ascii_digit() => {
            let idx = ch.to_digit(10)? as usize;
            if idx > 0 && idx <= visible_count {
                Some(Action::Ai(idx - 1))
            } else {
                None
            }
        }
        _ => None,
    }
}

fn print_action_bar(words: &[String], shown: usize, available: usize) -> Result<ActionBar> {
    let mut line = String::from("操作 ");
    let mut zones = Vec::new();

    for (idx, word) in words.iter().enumerate() {
        if !line.ends_with(' ') {
            line.push_str("  ");
        }
        let label = format!("[{} AI {}]", idx + 1, compact_word(word, 14));
        push_zone(&mut line, &mut zones, label, Action::Ai(idx));
    }

    if shown < available {
        line.push_str("  ");
        push_zone(
            &mut line,
            &mut zones,
            "[n 加载更多]".to_string(),
            Action::More,
        );
    }

    line.push_str("  ");
    push_zone(&mut line, &mut zones, "[q 退出]".to_string(), Action::Quit);
    println!("{line}");
    io::stdout().flush()?;
    Ok(ActionBar { row: None, zones })
}

fn push_zone(line: &mut String, zones: &mut Vec<ActionZone>, label: String, action: Action) {
    let start = UnicodeWidthStr::width(line.as_str()) as u16;
    line.push_str(&label);
    let end = UnicodeWidthStr::width(line.as_str()).saturating_sub(1) as u16;
    zones.push(ActionZone { start, end, action });
}

fn compact_word(word: &str, max_chars: usize) -> String {
    let mut chars = word.chars();
    let value = chars.by_ref().take(max_chars).collect::<String>();
    if chars.next().is_some() {
        format!("{value}...")
    } else {
        value
    }
}

struct InteractiveTerminal {
    active: bool,
}

impl InteractiveTerminal {
    fn start() -> Result<Self> {
        enable_raw_mode()?;
        execute!(io::stdout(), EnableMouseCapture)?;
        Ok(Self { active: true })
    }

    fn suspend(&mut self) -> Result<()> {
        if self.active {
            execute!(io::stdout(), DisableMouseCapture)?;
            drain_pending_events(Duration::from_millis(80))?;
            disable_raw_mode()?;
            self.active = false;
        }
        Ok(())
    }

    fn resume(&mut self) -> Result<()> {
        if !self.active {
            enable_raw_mode()?;
            execute!(io::stdout(), EnableMouseCapture)?;
            self.active = true;
        }
        Ok(())
    }
}

fn drain_pending_events(timeout: Duration) -> Result<()> {
    let deadline = Instant::now() + timeout;
    while Instant::now() < deadline {
        let remaining = deadline.saturating_duration_since(Instant::now());
        let poll_for = remaining.min(Duration::from_millis(10));
        if !event::poll(poll_for)? {
            continue;
        }
        let _ = event::read()?;
    }
    Ok(())
}

impl Drop for InteractiveTerminal {
    fn drop(&mut self) {
        if self.active {
            let _ = execute!(io::stdout(), DisableMouseCapture);
            let _ = disable_raw_mode();
        }
    }
}