use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextDirection {
Ltr,
Rtl,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocaleCoverage {
English,
V076Core,
PlannedQa,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LocaleSpec {
pub tag: &'static str,
pub display_name: &'static str,
pub script: &'static str,
pub direction: TextDirection,
pub fallback: &'static str,
pub coverage: LocaleCoverage,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Locale {
En,
Ja,
ZhHans,
PtBr,
}
impl Locale {
pub fn tag(self) -> &'static str {
match self {
Self::En => "en",
Self::Ja => "ja",
Self::ZhHans => "zh-Hans",
Self::PtBr => "pt-BR",
}
}
#[allow(dead_code)]
pub fn spec(self) -> LocaleSpec {
match self {
Self::En => LocaleSpec {
tag: "en",
display_name: "English",
script: "Latin",
direction: TextDirection::Ltr,
fallback: "en",
coverage: LocaleCoverage::English,
},
Self::Ja => LocaleSpec {
tag: "ja",
display_name: "Japanese",
script: "Jpan",
direction: TextDirection::Ltr,
fallback: "en",
coverage: LocaleCoverage::V076Core,
},
Self::ZhHans => LocaleSpec {
tag: "zh-Hans",
display_name: "Chinese Simplified",
script: "Hans",
direction: TextDirection::Ltr,
fallback: "en",
coverage: LocaleCoverage::V076Core,
},
Self::PtBr => LocaleSpec {
tag: "pt-BR",
display_name: "Portuguese (Brazil)",
script: "Latin",
direction: TextDirection::Ltr,
fallback: "en",
coverage: LocaleCoverage::V076Core,
},
}
}
#[allow(dead_code)]
pub fn shipped() -> &'static [Self] {
&[Self::En, Self::Ja, Self::ZhHans, Self::PtBr]
}
}
#[allow(dead_code)]
pub const PLANNED_QA_LOCALES: &[LocaleSpec] = &[
LocaleSpec {
tag: "ar",
display_name: "Arabic",
script: "Arab",
direction: TextDirection::Rtl,
fallback: "en",
coverage: LocaleCoverage::PlannedQa,
},
LocaleSpec {
tag: "hi",
display_name: "Hindi",
script: "Deva",
direction: TextDirection::Ltr,
fallback: "en",
coverage: LocaleCoverage::PlannedQa,
},
LocaleSpec {
tag: "bn",
display_name: "Bengali",
script: "Beng",
direction: TextDirection::Ltr,
fallback: "en",
coverage: LocaleCoverage::PlannedQa,
},
LocaleSpec {
tag: "id",
display_name: "Indonesian",
script: "Latin",
direction: TextDirection::Ltr,
fallback: "en",
coverage: LocaleCoverage::PlannedQa,
},
LocaleSpec {
tag: "vi",
display_name: "Vietnamese",
script: "Latin",
direction: TextDirection::Ltr,
fallback: "en",
coverage: LocaleCoverage::PlannedQa,
},
LocaleSpec {
tag: "sw",
display_name: "Swahili",
script: "Latin",
direction: TextDirection::Ltr,
fallback: "en",
coverage: LocaleCoverage::PlannedQa,
},
LocaleSpec {
tag: "ha",
display_name: "Hausa",
script: "Latin",
direction: TextDirection::Ltr,
fallback: "en",
coverage: LocaleCoverage::PlannedQa,
},
LocaleSpec {
tag: "yo",
display_name: "Yoruba",
script: "Latin",
direction: TextDirection::Ltr,
fallback: "en",
coverage: LocaleCoverage::PlannedQa,
},
LocaleSpec {
tag: "es-419",
display_name: "Spanish (Latin America)",
script: "Latin",
direction: TextDirection::Ltr,
fallback: "en",
coverage: LocaleCoverage::PlannedQa,
},
LocaleSpec {
tag: "fr",
display_name: "French",
script: "Latin",
direction: TextDirection::Ltr,
fallback: "en",
coverage: LocaleCoverage::PlannedQa,
},
LocaleSpec {
tag: "fil",
display_name: "Filipino/Tagalog",
script: "Latin",
direction: TextDirection::Ltr,
fallback: "en",
coverage: LocaleCoverage::PlannedQa,
},
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MessageId {
ComposerPlaceholder,
HistorySearchPlaceholder,
HistorySearchTitle,
HistoryHintMove,
HistoryHintAccept,
HistoryHintRestore,
HistoryNoMatches,
ConfigTitle,
ConfigModalTitle,
ConfigSearchPlaceholder,
ConfigNoSettings,
ConfigNoMatchesPrefix,
ConfigFilteredSettings,
ConfigShowing,
ConfigFooterDefault,
ConfigFooterScrollable,
ConfigFooterFiltered,
HelpTitle,
HelpFilterPlaceholder,
HelpFilterPrefix,
HelpNoMatches,
HelpSlashCommands,
HelpKeybindings,
HelpFooterTypeFilter,
HelpFooterMove,
HelpFooterJump,
HelpFooterClose,
}
#[allow(dead_code)]
pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::ComposerPlaceholder,
MessageId::HistorySearchPlaceholder,
MessageId::HistorySearchTitle,
MessageId::HistoryHintMove,
MessageId::HistoryHintAccept,
MessageId::HistoryHintRestore,
MessageId::HistoryNoMatches,
MessageId::ConfigTitle,
MessageId::ConfigModalTitle,
MessageId::ConfigSearchPlaceholder,
MessageId::ConfigNoSettings,
MessageId::ConfigNoMatchesPrefix,
MessageId::ConfigFilteredSettings,
MessageId::ConfigShowing,
MessageId::ConfigFooterDefault,
MessageId::ConfigFooterScrollable,
MessageId::ConfigFooterFiltered,
MessageId::HelpTitle,
MessageId::HelpFilterPlaceholder,
MessageId::HelpFilterPrefix,
MessageId::HelpNoMatches,
MessageId::HelpSlashCommands,
MessageId::HelpKeybindings,
MessageId::HelpFooterTypeFilter,
MessageId::HelpFooterMove,
MessageId::HelpFooterJump,
MessageId::HelpFooterClose,
];
pub fn tr(locale: Locale, id: MessageId) -> &'static str {
fallback_translation(translation(locale, id), id)
}
#[allow(dead_code)]
pub fn missing_message_ids(locale: Locale) -> Vec<MessageId> {
ALL_MESSAGE_IDS
.iter()
.copied()
.filter(|id| translation(locale, *id).is_none())
.collect()
}
pub fn normalize_configured_locale(input: &str) -> Option<&'static str> {
let normalized = normalize_locale_input(input);
if matches!(normalized.as_str(), "" | "auto" | "system") {
return Some("auto");
}
parse_locale(&normalized).map(Locale::tag)
}
pub fn resolve_locale(setting: &str) -> Locale {
resolve_locale_with_env(setting, |key| std::env::var(key).ok())
}
pub fn resolve_locale_with_env<F>(setting: &str, env: F) -> Locale
where
F: Fn(&str) -> Option<String>,
{
let normalized = normalize_locale_input(setting);
if !matches!(normalized.as_str(), "" | "auto" | "system") {
return parse_locale(&normalized).unwrap_or(Locale::En);
}
for key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
if let Some(value) = env(key)
&& let Some(locale) = parse_locale(&normalize_locale_input(&value))
{
return locale;
}
}
Locale::En
}
#[allow(dead_code)]
pub fn truncate_to_width(text: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
if text.width() <= max_width {
return text.to_string();
}
let ellipsis_width = '…'.width().unwrap_or(1);
if max_width <= ellipsis_width {
return "…".to_string();
}
let limit = max_width - ellipsis_width;
let mut out = String::new();
let mut width = 0usize;
for ch in text.chars() {
let ch_width = ch.width().unwrap_or(0);
if width + ch_width > limit {
break;
}
out.push(ch);
width += ch_width;
}
out.push('…');
out
}
fn normalize_locale_input(input: &str) -> String {
input
.split('.')
.next()
.unwrap_or(input)
.split('@')
.next()
.unwrap_or(input)
.trim()
.replace('_', "-")
.to_lowercase()
}
fn parse_locale(value: &str) -> Option<Locale> {
if value == "c" || value == "posix" || value.starts_with("en") {
return Some(Locale::En);
}
if value.starts_with("ja") {
return Some(Locale::Ja);
}
if value.starts_with("zh") {
if value.contains("hant")
|| value.contains("-tw")
|| value.contains("-hk")
|| value.contains("-mo")
{
return None;
}
return Some(Locale::ZhHans);
}
if value.starts_with("pt") || value == "br" {
return Some(Locale::PtBr);
}
None
}
fn fallback_translation(candidate: Option<&'static str>, id: MessageId) -> &'static str {
candidate.unwrap_or_else(|| english(id))
}
fn english(id: MessageId) -> &'static str {
match id {
MessageId::ComposerPlaceholder => "Write a task or use /.",
MessageId::HistorySearchPlaceholder => "Search prompt history...",
MessageId::HistorySearchTitle => "History Search",
MessageId::HistoryHintMove => "Up/Down move",
MessageId::HistoryHintAccept => "Enter accept",
MessageId::HistoryHintRestore => "Esc restore",
MessageId::HistoryNoMatches => " No matches",
MessageId::ConfigTitle => "Session Configuration",
MessageId::ConfigModalTitle => " Config ",
MessageId::ConfigSearchPlaceholder => "type to filter",
MessageId::ConfigNoSettings => " No settings available.",
MessageId::ConfigNoMatchesPrefix => " No settings match ",
MessageId::ConfigFilteredSettings => " Filtered settings",
MessageId::ConfigShowing => " Showing",
MessageId::ConfigFooterDefault => {
" type=filter, Up/Down=select, Enter/e=edit, Esc/q=close "
}
MessageId::ConfigFooterScrollable => {
" type=filter, Up/Down=select, Enter/e=edit, PgUp/PgDn=scroll, Esc/q=close "
}
MessageId::ConfigFooterFiltered => {
" type=filter, Backspace=delete, Ctrl+U/Esc=clear, Enter=edit "
}
MessageId::HelpTitle => "Help",
MessageId::HelpFilterPlaceholder => "Type to filter",
MessageId::HelpFilterPrefix => "Filter: ",
MessageId::HelpNoMatches => " No matches.",
MessageId::HelpSlashCommands => "Slash commands",
MessageId::HelpKeybindings => "Keybindings",
MessageId::HelpFooterTypeFilter => " type to filter ",
MessageId::HelpFooterMove => " Up/Down move ",
MessageId::HelpFooterJump => " PgUp/PgDn jump ",
MessageId::HelpFooterClose => " Esc close ",
}
}
fn translation(locale: Locale, id: MessageId) -> Option<&'static str> {
match locale {
Locale::En => Some(english(id)),
Locale::Ja => japanese(id),
Locale::ZhHans => chinese_simplified(id),
Locale::PtBr => portuguese_brazil(id),
}
}
fn japanese(id: MessageId) -> Option<&'static str> {
Some(match id {
MessageId::ComposerPlaceholder => "タスクを書くか / を使う。",
MessageId::HistorySearchPlaceholder => "プロンプト履歴を検索...",
MessageId::HistorySearchTitle => "履歴検索",
MessageId::HistoryHintMove => "Up/Down 移動",
MessageId::HistoryHintAccept => "Enter 確定",
MessageId::HistoryHintRestore => "Esc 復元",
MessageId::HistoryNoMatches => " 一致なし",
MessageId::ConfigTitle => "セッション設定",
MessageId::ConfigModalTitle => " 設定 ",
MessageId::ConfigSearchPlaceholder => "入力して絞り込み",
MessageId::ConfigNoSettings => " 設定がありません。",
MessageId::ConfigNoMatchesPrefix => " 一致する設定なし: ",
MessageId::ConfigFilteredSettings => " 絞り込み後の設定",
MessageId::ConfigShowing => " 表示",
MessageId::ConfigFooterDefault => {
" 入力=絞り込み, Up/Down=選択, Enter/e=編集, Esc/q=閉じる "
}
MessageId::ConfigFooterScrollable => {
" 入力=絞り込み, Up/Down=選択, Enter/e=編集, PgUp/PgDn=スクロール, Esc/q=閉じる "
}
MessageId::ConfigFooterFiltered => {
" 入力=絞り込み, Backspace=削除, Ctrl+U/Esc=クリア, Enter=編集 "
}
MessageId::HelpTitle => "ヘルプ",
MessageId::HelpFilterPlaceholder => "入力して絞り込み",
MessageId::HelpFilterPrefix => "絞り込み: ",
MessageId::HelpNoMatches => " 一致なし。",
MessageId::HelpSlashCommands => "スラッシュコマンド",
MessageId::HelpKeybindings => "キー操作",
MessageId::HelpFooterTypeFilter => " 入力して絞り込み ",
MessageId::HelpFooterMove => " Up/Down 移動 ",
MessageId::HelpFooterJump => " PgUp/PgDn ジャンプ ",
MessageId::HelpFooterClose => " Esc 閉じる ",
})
}
fn chinese_simplified(id: MessageId) -> Option<&'static str> {
Some(match id {
MessageId::ComposerPlaceholder => "编写任务或使用 /。",
MessageId::HistorySearchPlaceholder => "搜索提示历史...",
MessageId::HistorySearchTitle => "历史搜索",
MessageId::HistoryHintMove => "Up/Down 移动",
MessageId::HistoryHintAccept => "Enter 接受",
MessageId::HistoryHintRestore => "Esc 还原",
MessageId::HistoryNoMatches => " 无匹配",
MessageId::ConfigTitle => "会话配置",
MessageId::ConfigModalTitle => " 配置 ",
MessageId::ConfigSearchPlaceholder => "输入以筛选",
MessageId::ConfigNoSettings => " 没有可用设置。",
MessageId::ConfigNoMatchesPrefix => " 没有匹配设置: ",
MessageId::ConfigFilteredSettings => " 已筛选设置",
MessageId::ConfigShowing => " 显示",
MessageId::ConfigFooterDefault => " 输入=筛选, Up/Down=选择, Enter/e=编辑, Esc/q=关闭 ",
MessageId::ConfigFooterScrollable => {
" 输入=筛选, Up/Down=选择, Enter/e=编辑, PgUp/PgDn=滚动, Esc/q=关闭 "
}
MessageId::ConfigFooterFiltered => {
" 输入=筛选, Backspace=删除, Ctrl+U/Esc=清除, Enter=编辑 "
}
MessageId::HelpTitle => "帮助",
MessageId::HelpFilterPlaceholder => "输入以筛选",
MessageId::HelpFilterPrefix => "筛选: ",
MessageId::HelpNoMatches => " 无匹配。",
MessageId::HelpSlashCommands => "斜杠命令",
MessageId::HelpKeybindings => "快捷键",
MessageId::HelpFooterTypeFilter => " 输入以筛选 ",
MessageId::HelpFooterMove => " Up/Down 移动 ",
MessageId::HelpFooterJump => " PgUp/PgDn 跳转 ",
MessageId::HelpFooterClose => " Esc 关闭 ",
})
}
fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
Some(match id {
MessageId::ComposerPlaceholder => "Escreva uma tarefa ou use /.",
MessageId::HistorySearchPlaceholder => "Pesquisar histórico de prompts...",
MessageId::HistorySearchTitle => "Busca no histórico",
MessageId::HistoryHintMove => "Up/Down move",
MessageId::HistoryHintAccept => "Enter aceita",
MessageId::HistoryHintRestore => "Esc restaura",
MessageId::HistoryNoMatches => " Sem resultados",
MessageId::ConfigTitle => "Configuração da sessão",
MessageId::ConfigModalTitle => " Config ",
MessageId::ConfigSearchPlaceholder => "digite para filtrar",
MessageId::ConfigNoSettings => " Nenhuma configuração disponível.",
MessageId::ConfigNoMatchesPrefix => " Nenhuma configuração corresponde a ",
MessageId::ConfigFilteredSettings => " Configurações filtradas",
MessageId::ConfigShowing => " Mostrando",
MessageId::ConfigFooterDefault => {
" digite=filtrar, Up/Down=selecionar, Enter/e=editar, Esc/q=fechar "
}
MessageId::ConfigFooterScrollable => {
" digite=filtrar, Up/Down=selecionar, Enter/e=editar, PgUp/PgDn=rolar, Esc/q=fechar "
}
MessageId::ConfigFooterFiltered => {
" digite=filtrar, Backspace=apagar, Ctrl+U/Esc=limpar, Enter=editar "
}
MessageId::HelpTitle => "Ajuda",
MessageId::HelpFilterPlaceholder => "Digite para filtrar",
MessageId::HelpFilterPrefix => "Filtro: ",
MessageId::HelpNoMatches => " Sem resultados.",
MessageId::HelpSlashCommands => "Comandos com barra",
MessageId::HelpKeybindings => "Atalhos",
MessageId::HelpFooterTypeFilter => " digite para filtrar ",
MessageId::HelpFooterMove => " Up/Down move ",
MessageId::HelpFooterJump => " PgUp/PgDn salta ",
MessageId::HelpFooterClose => " Esc fecha ",
})
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{
buffer::Buffer,
layout::Rect,
widgets::{Paragraph, Widget, Wrap},
};
#[test]
fn locale_setting_normalizes_supported_tags() {
assert_eq!(normalize_configured_locale("auto"), Some("auto"));
assert_eq!(normalize_configured_locale("ja_JP.UTF-8"), Some("ja"));
assert_eq!(normalize_configured_locale("zh-CN"), Some("zh-Hans"));
assert_eq!(normalize_configured_locale("pt"), Some("pt-BR"));
assert_eq!(normalize_configured_locale("pt-PT"), Some("pt-BR"));
assert_eq!(normalize_configured_locale("zh-TW"), None);
}
#[test]
fn locale_resolution_uses_config_then_environment_then_english() {
assert_eq!(
resolve_locale_with_env("ja", |_| Some("pt_BR.UTF-8".to_string())),
Locale::Ja
);
assert_eq!(
resolve_locale_with_env("auto", |key| {
(key == "LANG").then(|| "zh_CN.UTF-8".to_string())
}),
Locale::ZhHans
);
assert_eq!(resolve_locale_with_env("auto", |_| None), Locale::En);
}
#[test]
fn shipped_first_pack_has_no_missing_core_messages() {
for locale in Locale::shipped() {
assert!(
missing_message_ids(*locale).is_empty(),
"{} is missing messages",
locale.tag()
);
}
}
#[test]
fn unsupported_locale_falls_back_to_english() {
assert_eq!(
resolve_locale_with_env("ar", |_| None),
Locale::En,
"Arabic is planned for QA but not shipped in the v0.7.6 core pack"
);
}
#[test]
fn missing_translation_falls_back_to_english() {
assert_eq!(
fallback_translation(None, MessageId::ComposerPlaceholder),
english(MessageId::ComposerPlaceholder)
);
}
#[test]
fn width_truncation_handles_cjk_rtl_indic_and_latin_samples() {
let samples = [
("zh-Hans", "输入以筛选配置"),
("ar", "تصفية الإعدادات"),
("hi", "सेटिंग खोजें"),
("pt-BR", "configurações filtradas"),
];
for (tag, sample) in samples {
let truncated = truncate_to_width(sample, 12);
assert!(
truncated.width() <= 12,
"{tag} sample overflowed: {truncated:?}"
);
}
}
#[test]
fn planned_script_samples_render_in_narrow_terminal_buffer() {
let samples = [
("CJK", "输入以筛选配置"),
("RTL", "تصفية الإعدادات"),
("Indic", "सेटिंग खोजें"),
("Latin Global South", "configurações filtradas"),
];
for (label, sample) in samples {
let area = Rect::new(0, 0, 18, 4);
let mut buf = Buffer::empty(area);
Paragraph::new(sample)
.wrap(Wrap { trim: false })
.render(area, &mut buf);
let dump = buffer_text(&buf, area);
assert!(
dump.chars().any(|ch| !ch.is_whitespace()),
"{label} sample produced an empty render"
);
}
}
fn buffer_text(buf: &Buffer, area: Rect) -> String {
let mut out = String::new();
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
}