use crate::config::AppConfig;
use anyhow::Result;
use dictx_core::{DictEntry, DictSource, SearchRequest};
use dictx_index::read_metadata;
use dictx_search::SearchResult;
use owo_colors::OwoColorize;
use serde_json::json;
use std::env;
use terminal_size::{terminal_size, Width};
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, Default)]
pub struct RenderedEntries {
pub shown: usize,
pub available: usize,
pub visible_words: Vec<String>,
}
impl RenderedEntries {
pub fn can_load_more(&self) -> bool {
self.shown < self.available
}
}
pub fn print_json_result(request: &SearchRequest, result: &SearchResult, raw: bool) -> Result<()> {
let entries: Vec<_> = result
.entries
.iter()
.map(|item| {
if raw {
json!({
"score": item.score,
"entry": item.entry,
})
} else {
json!({
"score": item.score,
"source": item.entry.source.display_name(),
"word": item.entry.word,
"phonetic": item.entry.phonetic(),
"definitions": item.entry.definitions,
"tags": item.entry.tags,
"freq_bnc": item.entry.freq_bnc,
"collins_star": item.entry.collins_star,
})
}
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&json!({
"query": request.query.user_text(),
"total": result.total,
"elapsed_ms": result.elapsed_ms,
"results": entries,
}))?
);
Ok(())
}
pub fn print_entries(
request: &SearchRequest,
result: &SearchResult,
color: bool,
raw: bool,
display_limit: usize,
interactive: bool,
) -> RenderedEntries {
if result.entries.is_empty() {
println!("未找到结果: {}", request.query.user_text());
return RenderedEntries::default();
}
print_header(request, result, color);
let rendered = print_entries_page(result, 0, display_limit, color, raw, interactive);
if rendered.can_load_more() && !raw {
print_load_more_hint(rendered.shown, rendered.available, color, interactive);
}
rendered
}
pub fn print_entries_page(
result: &SearchResult,
start: usize,
count: usize,
color: bool,
raw: bool,
interactive: bool,
) -> RenderedEntries {
let width = card_width();
let mut visible_words = Vec::new();
let end = start.saturating_add(count).min(result.entries.len());
for (idx, item) in result.entries.iter().enumerate().skip(start).take(count) {
if raw {
println!(
"{}",
serde_json::to_string_pretty(&item.entry).unwrap_or_default()
);
continue;
}
print_entry(
idx + 1,
idx.saturating_sub(start) + 1,
&item.entry,
item.score,
width,
color,
interactive,
);
if idx < end {
visible_words.push(item.entry.word.clone());
}
}
RenderedEntries {
shown: end,
available: result.entries.len(),
visible_words,
}
}
pub fn print_load_more_hint(shown: usize, available: usize, color: bool, interactive: bool) {
let remaining = available.saturating_sub(shown);
if remaining == 0 {
return;
}
if interactive && color {
println!(
"{} {} {}",
"还有".dimmed(),
remaining.to_string().bright_white().bold(),
"条结果。按 n 加载更多,按 1/2/3 查询当前卡片 AI,按 q 退出。".dimmed()
);
} else if interactive {
println!("还有 {remaining} 条结果。按 n 加载更多,按 1/2/3 查询当前卡片 AI,按 q 退出。");
} else if color {
println!(
"{} {} {}",
"还有".dimmed(),
remaining.to_string().bright_white().bold(),
"条结果。使用 --limit <数量> 显示更多。".dimmed()
);
} else {
println!("还有 {remaining} 条结果。使用 --limit <数量> 显示更多。");
}
}
pub fn print_ai_answer(term: &str, content: &str, color: bool) {
let width = card_width();
let title = format!("AI 详解 {}", term);
println!("{}", top_line(&title, width, color));
print_wrapped_meta(width, "在线 AI API · 结果供学习参考", color);
let mut printed = false;
for raw_line in content.lines() {
let line = normalize_ai_line(raw_line);
if line.is_empty() {
if printed {
print_spacer(width);
}
continue;
}
if let Some(section) = ai_section_title(&line) {
if printed {
print_spacer(width);
}
print_section(width, §ion, color);
printed = true;
continue;
}
let line = line
.strip_prefix("- ")
.or_else(|| line.strip_prefix("* "))
.unwrap_or(&line);
let style = if has_cjk(line) {
FieldStyle::Chinese
} else {
FieldStyle::English
};
print_wrapped_field(width, "AI", line, color, style);
printed = true;
}
if !printed {
print_wrapped_field(
width,
"AI",
"AI API 没有返回可显示内容。",
color,
FieldStyle::Chinese,
);
}
println!("╰{}╯", "─".repeat(width - 2));
}
fn print_header(request: &SearchRequest, result: &SearchResult, color: bool) {
if color {
println!(
"{} {} {} {} {} {} ms",
"DictX".bright_cyan().bold(),
request.query.user_text().bright_white().bold(),
"结果".dimmed(),
result.total.to_string().bright_white(),
"耗时".dimmed(),
result.elapsed_ms.to_string().bright_white()
);
} else {
println!(
"DictX {} 结果 {} 耗时 {} ms",
request.query.user_text(),
result.total,
result.elapsed_ms
);
}
}
fn print_entry(
index: usize,
action_slot: usize,
entry: &DictEntry,
score: f32,
width: usize,
color: bool,
interactive: bool,
) {
let title = format!(
"{}. {} {}",
index,
entry.word,
entry.phonetic().unwrap_or_default()
);
println!("{}", top_line(&title, width, color));
print_wrapped_meta(width, &meta_text(entry, score), color);
if !entry.tags.is_empty() {
print_badges(width, "标签", &entry.tags, color);
}
if !entry.definitions.is_empty() {
print_section(width, "释义", color);
for (idx, definition) in entry.definitions.iter().take(5).enumerate() {
let pos = definition
.pos
.as_deref()
.map(friendly_pos)
.filter(|value| !value.is_empty())
.map(|value| format!("{value} "))
.unwrap_or_default();
if !definition.zh.is_empty() {
print_wrapped_field(
width,
if idx == 0 { "中" } else { "中" },
&format!("{pos}{}", definition.zh),
color,
FieldStyle::Chinese,
);
}
if !definition.en.is_empty() {
print_wrapped_field(width, "EN", &definition.en, color, FieldStyle::English);
}
}
}
if !entry.phrases.is_empty() {
print_spacer(width);
print_section(width, "短语", color);
for phrase in entry.phrases.iter().take(4) {
let text = if phrase.zh.is_empty() {
phrase.en.clone()
} else {
format!("{} {}", phrase.en, phrase.zh)
};
print_wrapped_field(width, "搭", &text, color, FieldStyle::Phrase);
}
}
if !entry.examples.is_empty() {
print_spacer(width);
print_section(width, "例句", color);
for example in entry.examples.iter().take(2) {
if !example.en.is_empty() {
print_wrapped_field(width, "EN", &example.en, color, FieldStyle::ExampleEn);
}
if !example.zh.is_empty() {
print_wrapped_field(width, "中", &example.zh, color, FieldStyle::ExampleZh);
}
}
}
print_spacer(width);
print_ai_hint(width, entry, action_slot, color, interactive);
println!("╰{}╯", "─".repeat(width - 2));
}
fn meta_text(entry: &DictEntry, score: f32) -> String {
let mut parts = vec![
format!("来源 {}", friendly_source(&entry.source)),
format!("相关度 {:.1}", score),
];
if entry.collins_star > 0 {
parts.push(format!(
"Collins {}",
"★".repeat(entry.collins_star as usize)
));
}
if let Some(freq) = entry.freq_bnc {
parts.push(format!("BNC #{freq}"));
}
parts.join(" · ")
}
fn print_wrapped_meta(width: usize, text: &str, color: bool) {
let text = if color {
text.dimmed().to_string()
} else {
text.to_string()
};
for line in wrap_ansi_text(&text, width - 8) {
print_box_line(width, &format!(" {line}"));
}
}
fn print_badges(width: usize, title: &str, tags: &[String], color: bool) {
let labels = tags.iter().fold(Vec::<String>::new(), |mut labels, tag| {
let label = friendly_tag(tag);
if !labels.contains(&label) {
labels.push(label);
}
labels
});
let badges = labels
.iter()
.map(|label| {
if color {
format!("{}", format!("[{label}]").bright_magenta())
} else {
format!("[{label}]")
}
})
.collect::<Vec<_>>()
.join(" ");
let title = if color {
title.bright_black().to_string()
} else {
title.to_string()
};
for line in wrap_ansi_text(&badges, width - 12) {
print_box_line(width, &format!(" {title} {line}"));
}
}
fn print_section(width: usize, title: &str, color: bool) {
let title = if color {
format!("{}", title.bright_yellow().bold())
} else {
title.to_string()
};
print_box_line(width, &format!(" {title}"));
}
fn print_wrapped_field(width: usize, label: &str, text: &str, color: bool, style: FieldStyle) {
let label_plain = format!("[{label}]");
let label = paint_label(&label_plain, color, style);
let prefix_width = UnicodeWidthStr::width(label_plain.as_str()) + 1;
let continuation = " ".repeat(prefix_width);
let content_width = width.saturating_sub(4 + 2 + prefix_width).max(16);
let mut lines = wrap_plain_text(text, content_width);
if lines.is_empty() {
lines.push(String::new());
}
for (idx, line) in lines.into_iter().enumerate() {
let text = paint_text(&line, color, style);
if idx == 0 {
print_box_line(width, &format!(" {label} {text}"));
} else {
print_box_line(width, &format!(" {continuation}{text}"));
}
}
}
fn print_ai_hint(
width: usize,
entry: &DictEntry,
action_slot: usize,
color: bool,
interactive: bool,
) {
let command = ai_command(&entry.word);
let label = if color {
"AI 详解".bright_blue().bold().to_string()
} else {
"AI 详解".to_string()
};
let prefix = if color {
" ↳".bright_black().to_string()
} else {
" ->".to_string()
};
let action = if interactive && color {
format!(
"{} {} {}",
"按".dimmed(),
action_slot.to_string().bright_white().bold(),
"或点击底部按钮".dimmed()
)
} else if interactive {
format!("按 {action_slot} 或点击底部按钮")
} else if color {
"运行".dimmed().to_string()
} else {
"运行".to_string()
};
let text = format!("{prefix} {label} {action} {command}");
for line in wrap_ansi_text(&text, width - 6) {
print_box_line(width, &line);
}
}
pub fn ai_command(word: &str) -> String {
format!("{} ai {}", executable_command(), shell_quote(word))
}
pub fn print_source_list(config: &AppConfig) -> Result<()> {
println!("词库目录: {}", config.dict_dir.display());
println!("索引目录: {}", config.index_dir.display());
for source in &config.sources {
let status = if source.enabled { "启用" } else { "停用" };
let meta = read_metadata(&config.index_dir_for(&source.name))?;
let index = meta
.map(|meta| {
format!(
"索引 {} 条 / {}",
meta.entries,
human_bytes(meta.index_bytes)
)
})
.unwrap_or_else(|| "索引未构建".to_string());
println!(
"- {} [{}] {} | {} | {}",
source.name,
status,
source.format,
source_location(source),
index
);
}
Ok(())
}
fn source_location(source: &crate::config::SourceConfig) -> String {
let value = source.path.to_string_lossy();
if value.starts_with("builtin:") {
format!("内置包 {value}")
} else {
source.path.display().to_string()
}
}
#[derive(Debug, Clone, Copy)]
enum FieldStyle {
Chinese,
English,
ExampleEn,
ExampleZh,
Phrase,
}
fn paint_label(label: &str, color: bool, style: FieldStyle) -> String {
if !color {
return label.to_string();
}
match style {
FieldStyle::Chinese => label.green().bold().to_string(),
FieldStyle::English => label.blue().bold().to_string(),
FieldStyle::ExampleEn => label.cyan().bold().to_string(),
FieldStyle::ExampleZh => label.green().bold().to_string(),
FieldStyle::Phrase => label.yellow().bold().to_string(),
}
}
fn paint_text(text: &str, color: bool, style: FieldStyle) -> String {
if !color {
return text.to_string();
}
match style {
FieldStyle::Chinese => text.green().bold().to_string(),
FieldStyle::English => text.blue().to_string(),
FieldStyle::ExampleEn => text.cyan().to_string(),
FieldStyle::ExampleZh => text.green().to_string(),
FieldStyle::Phrase => text.yellow().to_string(),
}
}
fn friendly_source(source: &DictSource) -> String {
match source {
DictSource::Ecdict => "ECDICT".to_string(),
DictSource::Anki { deck_name } if deck_name.eq_ignore_ascii_case("KaoYan_3") => {
"考研词库".to_string()
}
DictSource::Anki { deck_name } => deck_name.clone(),
DictSource::Sqlite { name, table } if name == "kd_data" && table == "en" => {
"金山词霸 英汉".to_string()
}
DictSource::Sqlite { name, table } if name == "kd_data" && table == "ch" => {
"金山词霸 汉英".to_string()
}
DictSource::Sqlite { name, table } => format!("{name}:{table}"),
DictSource::Mdx { filename } => filename.clone(),
DictSource::Custom { name } => name.clone(),
}
}
fn friendly_tag(tag: &str) -> String {
match tag.trim().to_ascii_lowercase().as_str() {
"zk" => "中考".to_string(),
"gk" => "高考".to_string(),
"cet4" => "CET-4".to_string(),
"cet6" => "CET-6".to_string(),
"tem4" => "TEM-4".to_string(),
"tem8" => "TEM-8".to_string(),
"kao_yan" | "kaoyan" | "kaoyan_3" => "考研".to_string(),
"toefl" => "TOEFL".to_string(),
"ielts" => "IELTS".to_string(),
"gre" => "GRE".to_string(),
"gmat" => "GMAT".to_string(),
"sat" => "SAT".to_string(),
other => other.to_ascii_uppercase().replace('_', "-"),
}
}
fn friendly_pos(pos: &str) -> String {
let pos = pos.trim().trim_end_matches('.');
match pos.to_ascii_lowercase().as_str() {
"n" => "名词 n.".to_string(),
"n-var" => "名词 n-var.".to_string(),
"n-count" => "可数名词 n-count.".to_string(),
"n-uncount" => "不可数名词 n-uncount.".to_string(),
"v" => "动词 v.".to_string(),
"vt" | "v-t" => "及物动词 vt.".to_string(),
"vi" | "v-i" => "不及物动词 vi.".to_string(),
"adj" => "形容词 adj.".to_string(),
"adv" => "副词 adv.".to_string(),
"prep" => "介词 prep.".to_string(),
"conj" => "连词 conj.".to_string(),
"pron" => "代词 pron.".to_string(),
"num" => "数词 num.".to_string(),
other if other.is_empty() => String::new(),
other => format!("{other}."),
}
}
fn top_line(title: &str, width: usize, color: bool) -> String {
let title = if color {
title.bright_cyan().bold().to_string()
} else {
title.to_string()
};
let plain_width = display_width(&title);
let fill = width.saturating_sub(plain_width + 5);
format!("╭─ {} {}╮", title, "─".repeat(fill))
}
fn print_box_line(width: usize, text: &str) {
println!("│ {} │", pad(text, width - 4));
}
fn print_spacer(width: usize) {
print_box_line(width, "");
}
fn pad(text: &str, width: usize) -> String {
let used = display_width(text);
if used >= width {
truncate_width(text, width)
} else {
format!("{text}{}", " ".repeat(width - used))
}
}
fn wrap_plain_text(text: &str, max_width: usize) -> Vec<String> {
let text = normalize_spaces(text);
if text.is_empty() {
return Vec::new();
}
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split(' ') {
if word.is_empty() {
continue;
}
let word_width = UnicodeWidthStr::width(word);
if word_width > max_width {
if !current.is_empty() {
lines.push(current);
current = String::new();
}
lines.extend(split_long_word(word, max_width));
continue;
}
let next_width = if current.is_empty() {
word_width
} else {
UnicodeWidthStr::width(current.as_str()) + 1 + word_width
};
if next_width > max_width && !current.is_empty() {
lines.push(current);
current = word.to_string();
} else {
if !current.is_empty() {
current.push(' ');
}
current.push_str(word);
}
}
if !current.is_empty() {
lines.push(current);
}
lines
}
fn wrap_ansi_text(text: &str, max_width: usize) -> Vec<String> {
if display_width(text) <= max_width {
return vec![text.to_string()];
}
wrap_plain_text(&strip_ansi(text), max_width)
}
fn split_long_word(word: &str, max_width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut current = String::new();
let mut width = 0usize;
for ch in word.chars() {
let ch_width = UnicodeWidthStr::width(ch.to_string().as_str());
if width + ch_width > max_width && !current.is_empty() {
lines.push(current);
current = String::new();
width = 0;
}
current.push(ch);
width += ch_width;
}
if !current.is_empty() {
lines.push(current);
}
lines
}
fn normalize_spaces(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn truncate_width(text: &str, width: usize) -> String {
let mut out = String::new();
let mut used = 0usize;
let plain = strip_ansi(text);
for ch in plain.chars() {
let ch_width = UnicodeWidthStr::width(ch.to_string().as_str());
if used + ch_width + 1 > width {
break;
}
out.push(ch);
used += ch_width;
}
out.push('…');
let used = UnicodeWidthStr::width(out.as_str());
if used < width {
out.push_str(&" ".repeat(width - used));
}
out
}
fn display_width(text: &str) -> usize {
UnicodeWidthStr::width(strip_ansi(text).as_str())
}
fn strip_ansi(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\u{1b}' {
match chars.next() {
Some('[') => {
for next in chars.by_ref() {
if next.is_ascii_alphabetic() {
break;
}
}
}
Some(']') => {
let mut previous_was_esc = false;
for next in chars.by_ref() {
if next == '\u{7}' {
break;
}
if previous_was_esc && next == '\\' {
break;
}
previous_was_esc = next == '\u{1b}';
}
}
_ => {}
}
} else {
out.push(ch);
}
}
out
}
fn card_width() -> usize {
env::var("COLUMNS")
.ok()
.and_then(|value| value.parse::<usize>().ok())
.or_else(|| terminal_size().map(|(Width(width), _)| width as usize))
.unwrap_or(96)
.clamp(76, 124)
}
fn executable_command() -> String {
let exe = env::current_exe().ok();
let cwd = env::current_dir().ok();
match (exe, cwd) {
(Some(exe), Some(cwd)) if exe.parent() == Some(cwd.as_path()) => exe
.file_name()
.and_then(|name| name.to_str())
.map(|name| format!("./{name}"))
.unwrap_or_else(|| "dictx".to_string()),
(Some(exe), _) => exe.display().to_string(),
_ => "dictx".to_string(),
}
}
fn normalize_ai_line(line: &str) -> String {
let line = line
.trim()
.replace("**", "")
.replace("__", "")
.replace('`', "")
.replace('*', "");
let line = line.trim().trim_matches('*').trim();
if line.chars().all(|ch| matches!(ch, '-' | '_' | '*')) {
return String::new();
}
line.trim_start_matches('#').trim().to_string()
}
fn ai_section_title(line: &str) -> Option<String> {
let trimmed = line.trim();
let lower = trimmed.to_ascii_lowercase();
let title = if lower.ends_with(':') || trimmed.ends_with(':') {
trimmed.trim_end_matches(':').trim_end_matches(':')
} else {
trimmed
};
match title {
"中文释义" | "英文释义" | "常用搭配" | "例句" | "词性" | "用法" | "辨析" => {
Some(title.to_string())
}
value
if value.chars().count() <= 24
&& !value.contains(':')
&& !value.contains(':')
&& (value.contains("名词")
|| value.contains("动词")
|| value.contains("形容词")
|| value.contains("副词")
|| lower.contains("noun")
|| lower.contains("verb")
|| lower.contains("adjective")
|| lower.contains("adverb")) =>
{
Some(value.to_string())
}
_ => None,
}
}
fn has_cjk(text: &str) -> bool {
text.chars().any(|ch| {
('\u{4e00}'..='\u{9fff}').contains(&ch)
|| ('\u{3400}'..='\u{4dbf}').contains(&ch)
|| ('\u{f900}'..='\u{faff}').contains(&ch)
})
}
fn shell_quote(value: &str) -> String {
if value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
{
return value.to_string();
}
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
fn human_bytes(bytes: u64) -> String {
const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
let mut value = bytes as f64;
let mut unit = 0usize;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{} {}", bytes, UNITS[unit])
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wraps_long_sentence_without_truncating() {
let text = "The Russian foreign minister yesterday cancelled his visit to Washington.";
let lines = wrap_plain_text(text, 24);
assert!(lines.len() > 1);
assert_eq!(lines.join(" "), text);
assert!(lines
.iter()
.all(|line| UnicodeWidthStr::width(line.as_str()) <= 24));
}
#[test]
fn formats_friendly_tags() {
assert_eq!(friendly_tag("cet4"), "CET-4");
assert_eq!(friendly_tag("kao_yan"), "考研");
}
#[test]
fn normalizes_ai_markdown_lines() {
assert_eq!(normalize_ai_line("## 名词 (noun)"), "名词 (noun)");
assert_eq!(
normalize_ai_line("`take a test` 参加考试"),
"take a test 参加考试"
);
assert_eq!(normalize_ai_line("*She has a test.*"), "She has a test.");
assert_eq!(normalize_ai_line("---"), "");
assert_eq!(
ai_section_title("名词 (noun)").as_deref(),
Some("名词 (noun)")
);
assert_eq!(ai_section_title("词性:名词 / 动词"), None);
}
}