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();
}
}
}