From 793ab720f63fc486e594b6fb4d1d60396328b67e Mon Sep 17 00:00:00 2001 From: Codex GPT-5 Date: Thu, 3 Sep 2026 09:39:34 +0200 Subject: [PATCH 1/6] feat: add Korean help translation Add Korean locale detection and a complete Korean translation for the interactive help pane. Generalize the existing UTF-8 locale parser so supported language codes share one path, while preserving English fallback for non-UTF-8 locales.\n\nRegression: ko_KR.UTF-8 and ko now select Korean; ko_KR.EUC-KR falls back to English.\n\nValidated with: cargo test interactive::widgets::i18n::tests --- src/interactive/widgets/i18n.rs | 98 +++++++++++++++++++++++++++++---- 1 file changed, 88 insertions(+), 10 deletions(-) diff --git a/src/interactive/widgets/i18n.rs b/src/interactive/widgets/i18n.rs index 902be33a..8209966d 100644 --- a/src/interactive/widgets/i18n.rs +++ b/src/interactive/widgets/i18n.rs @@ -13,6 +13,7 @@ pub enum Language { #[default] English, Japanese, + Korean, } impl Language { @@ -30,6 +31,7 @@ impl Language { match self { Language::English => &EN, Language::Japanese => &JA, + Language::Korean => &KO, } } } @@ -38,10 +40,8 @@ impl Language { /// /// Empty values are treated as unset (as glibc does) and fall through to the /// next variable. The language code is the part before the first `_`, `.` or -/// `@`. Missing codesets are treated as UTF-8, so `ja`, `ja_JP`, -/// `ja_JP.UTF-8`, `ja.UTF8` and `ja_JP.UTF-8@modifier` map to -/// [`Language::Japanese`]. Explicit non-UTF-8 Japanese codesets such as -/// `ja_JP.SJIS` map to [`Language::English`]. +/// `@`. Missing codesets are treated as UTF-8. Supported languages with an +/// explicit non-UTF-8 codeset map to [`Language::English`]. fn detect(locales: impl IntoIterator>) -> Language where S: AsRef, @@ -50,13 +50,17 @@ where .into_iter() .flatten() .find(|value| !value.as_ref().is_empty()); - match locale { - Some(locale) if is_japanese_utf8(locale.as_ref()) => Language::Japanese, + match locale + .as_ref() + .and_then(|locale| utf8_language(locale.as_ref())) + { + Some("ja") => Language::Japanese, + Some("ko") => Language::Korean, _ => Language::English, } } -fn is_japanese_utf8(locale: &str) -> bool { +fn utf8_language(locale: &str) -> Option<&str> { let locale = locale.split_once('@').map_or(locale, |(locale, _)| locale); let (language_region, codeset) = match locale.split_once('.') { Some((language_region, codeset)) => (language_region, Some(codeset)), @@ -65,7 +69,7 @@ fn is_japanese_utf8(locale: &str) -> bool { let language = language_region .split_once('_') .map_or(language_region, |(language, _)| language); - language == "ja" && codeset.is_none_or(is_utf8_codeset) + codeset.is_none_or(is_utf8_codeset).then_some(language) } fn is_utf8_codeset(codeset: &str) -> bool { @@ -269,6 +273,70 @@ const JA: HelpText = HelpText { app_quit: "アプリケーションを終了する。確認なし!", }; +const KO: HelpText = HelpText { + block_title: "도움말", + + pane_control_title: "패널 제어", + pane_q_quit: "현재 패널을 닫습니다. 기본 화면에서는 종료합니다(확인이 필요할 수 있음).", + pane_esc_close: "현재 패널을 닫습니다.", + pane_esc_close_2: "기본 화면에서는 상위 디렉터리로 이동합니다.", + pane_qesc_close: "현재 패널을 닫습니다.", + pane_qesc_close_2: "열린 패널이 없으면 프로그램을 종료합니다.", + pane_tab: "열린 모든 패널을 순환합니다.", + pane_tab_2: "'표시된 항목' 패널을 활성화하여 선택한 파일을 삭제합니다.", + pane_help_toggle: "이 도움말 패널을 표시하거나 숨깁니다.", + + nav_title: "탐색", + nav_down: "한 항목 아래로 이동합니다.", + nav_up: "한 항목 위로 이동합니다.", + nav_descend: "선택한 디렉터리로 들어갑니다.", + nav_ascend: "상위 디렉터리로 한 단계 이동합니다.", + nav_down10: "10개 항목 아래로 이동합니다.", + nav_up10: "10개 항목 위로 이동합니다.", + nav_top: "목록의 맨 위로 이동합니다.", + nav_bottom: "목록의 맨 아래로 이동합니다.", + + disp_title: "표시", + disp_sort_size: "크기 기준 내림차순/오름차순 정렬을 전환합니다.", + disp_sort_mtime: "수정 시간 기준 내림차순/오름차순 정렬을 전환합니다.", + disp_show_mtime: "수정 시간을 표시하거나 mtime 정렬 모드를 순환합니다.", + disp_show_mtime_2: "mtime 정렬 중: 항목, 하위 항목 중 최신, 하위 항목 중 가장 오래됨.", + disp_sort_count: "항목 수 기준 내림차순/오름차순 정렬을 전환합니다.", + disp_show_count: "항목 수를 표시하거나 숨깁니다.", + disp_sort_name: "이름 기준 오름차순/내림차순 정렬을 전환합니다.", + disp_cycle_bar: "백분율 및 막대 표시 옵션을 순환합니다.", + + oms_title: "열기/표시/검색", + oms_open: "선택한 항목을 연결된 프로그램으로 엽니다.", + oms_toggle_down: "현재 선택한 항목의 표시 상태를 전환하고 아래로 이동합니다.", + oms_mark_down: "현재 선택한 항목을 삭제 대상으로 표시하고 아래로 이동합니다.", + oms_toggle: "현재 선택한 항목의 표시 상태를 전환합니다.", + oms_mark_cleanup: "현재 보기에서 정리 후보를 표시합니다.", + oms_toggle_cleanup: "정리 후보 감지를 전환합니다.", + oms_mark_gitignored: "현재 보기에서 Git이 무시하는 항목을 표시합니다.", + oms_toggle_gitignored: "Git 무시 항목 감지를 전환합니다.", + oms_toggle_all: "모든 항목의 표시 상태를 전환합니다.", + oms_search: "Git 방식의 glob 검색.", + oms_search_2: "검색은 현재 디렉터리에서 시작됩니다.", + oms_refresh_one: "선택한 항목만 새로 고칩니다.", + oms_refresh_all: "현재 보기의 모든 항목을 새로 고칩니다.", + + mark_title: "표시된 항목 패널", + mark_remove: "선택한 항목을 목록에서 제거합니다.", + mark_remove_all: "모든 항목을 목록에서 제거합니다.", + mark_delete: "표시된 모든 항목을 확인 없이 영구적으로 삭제합니다.", + mark_delete_2: "이 작업은 취소할 수 없습니다!", + #[cfg(feature = "trash-move")] + mark_trash: "표시된 모든 항목을 휴지통으로 이동합니다.", + #[cfg(feature = "trash-move")] + mark_trash_2: "항목을 휴지통에서 복원할 수 있습니다.", + + app_title: "애플리케이션 제어", + app_suspend: "애플리케이션을 일시 중단하고 셸로 제어권을 돌려줍니다.", + app_repaint: "화면을 지우고 다시 그립니다.", + app_quit: "애플리케이션을 종료합니다. 확인하지 않습니다!", +}; + #[cfg(test)] mod tests { use super::*; @@ -300,7 +368,13 @@ mod tests { } #[test] - fn explicit_non_utf8_japanese_locales_are_english() { + fn korean_locale_selects_korean_when_codeset_is_missing_or_utf8() { + assert_eq!(detect([None, None, Some("ko_KR.UTF-8")]), Language::Korean); + assert_eq!(detect([None, None, Some("ko")]), Language::Korean); + } + + #[test] + fn explicit_non_utf8_supported_locales_are_english() { assert_eq!( detect([None, None, Some("ja_JP.SJIS")]), Language::English, @@ -310,10 +384,14 @@ mod tests { detect([None, None, Some("ja_JP.EUC-JP")]), Language::English ); + assert_eq!( + detect([None, None, Some("ko_KR.EUC-KR")]), + Language::English + ); } #[test] - fn non_japanese_locales_are_english() { + fn unsupported_locales_are_english() { assert_eq!(detect([None, None, Some("en_US.UTF-8")]), Language::English); assert_eq!(detect([None, None, Some("C")]), Language::English); assert_eq!(detect([None, None, Some("POSIX")]), Language::English); From a7835a24a5a1a627520aabc7f3f20c637a090fd7 Mon Sep 17 00:00:00 2001 From: Codex GPT-5 Date: Thu, 3 Sep 2026 09:42:16 +0200 Subject: [PATCH 2/6] docs: list Korean help locale Update the interactive-mode documentation after Korean locale support was added. This addresses the post-commit review finding for 793ab720. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0a4b23ce..99351b2b 100644 --- a/README.md +++ b/README.md @@ -236,11 +236,12 @@ dua interactive ``` The help screen can be localized via the standard POSIX locale environment variables, in the -usual order of precedence `LC_ALL` > `LC_MESSAGES` > `LANG`. English is the default; currently -Japanese (`ja`) is also available when the locale uses UTF-8 or omits the codeset: +usual order of precedence `LC_ALL` > `LC_MESSAGES` > `LANG`. English is the default; Japanese +(`ja`) and Korean (`ko`) are also available when the locale uses UTF-8 or omits the codeset: ```bash LANG=ja_JP.UTF-8 dua i # then press '?' for the Japanese help screen +LANG=ko_KR.UTF-8 dua i # then press '?' for the Korean help screen ``` ### Flame graphs From 7b5e8e48d62dba73c2c66c6b5876f696c684926f Mon Sep 17 00:00:00 2001 From: Codex GPT-5 Date: Thu, 3 Sep 2026 09:44:01 +0200 Subject: [PATCH 3/6] feat: add Simplified Chinese help translation Add zh locale detection and a complete Simplified Chinese translation for the interactive help pane, with documentation. Regression coverage verifies zh_CN.UTF-8 and zh selection plus English fallback for zh_CN.GB18030. Validated with: cargo test interactive::widgets::i18n::tests --- README.md | 4 +- src/interactive/widgets/i18n.rs | 77 +++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 99351b2b..b33358c3 100644 --- a/README.md +++ b/README.md @@ -237,11 +237,13 @@ dua interactive The help screen can be localized via the standard POSIX locale environment variables, in the usual order of precedence `LC_ALL` > `LC_MESSAGES` > `LANG`. English is the default; Japanese -(`ja`) and Korean (`ko`) are also available when the locale uses UTF-8 or omits the codeset: +(`ja`), Korean (`ko`), and Simplified Chinese (`zh`) are also available when the locale uses UTF-8 +or omits the codeset: ```bash LANG=ja_JP.UTF-8 dua i # then press '?' for the Japanese help screen LANG=ko_KR.UTF-8 dua i # then press '?' for the Korean help screen +LANG=zh_CN.UTF-8 dua i # then press '?' for the Simplified Chinese help screen ``` ### Flame graphs diff --git a/src/interactive/widgets/i18n.rs b/src/interactive/widgets/i18n.rs index 8209966d..860c0219 100644 --- a/src/interactive/widgets/i18n.rs +++ b/src/interactive/widgets/i18n.rs @@ -14,6 +14,7 @@ pub enum Language { English, Japanese, Korean, + Chinese, } impl Language { @@ -32,6 +33,7 @@ impl Language { Language::English => &EN, Language::Japanese => &JA, Language::Korean => &KO, + Language::Chinese => &ZH, } } } @@ -56,6 +58,7 @@ where { Some("ja") => Language::Japanese, Some("ko") => Language::Korean, + Some("zh") => Language::Chinese, _ => Language::English, } } @@ -337,6 +340,70 @@ const KO: HelpText = HelpText { app_quit: "애플리케이션을 종료합니다. 확인하지 않습니다!", }; +const ZH: HelpText = HelpText { + block_title: "帮助", + + pane_control_title: "面板控制", + pane_q_quit: "关闭当前面板。在主视图中退出(可能需要确认)。", + pane_esc_close: "关闭当前面板。", + pane_esc_close_2: "在主视图中,返回上级目录。", + pane_qesc_close: "关闭当前面板。", + pane_qesc_close_2: "如果没有打开的面板,则退出程序。", + pane_tab: "在所有打开的面板之间循环切换。", + pane_tab_2: "激活“已标记项目”面板以删除所选文件。", + pane_help_toggle: "显示或隐藏此帮助面板。", + + nav_title: "导航", + nav_down: "向下移动 1 个条目。", + nav_up: "向上移动 1 个条目。", + nav_descend: "进入所选目录。", + nav_ascend: "返回上一级目录。", + nav_down10: "向下移动 10 个条目。", + nav_up10: "向上移动 10 个条目。", + nav_top: "移到列表顶部。", + nav_bottom: "移到列表底部。", + + disp_title: "显示", + disp_sort_size: "在按大小降序/升序排序之间切换。", + disp_sort_mtime: "在按修改时间降序/升序排序之间切换。", + disp_show_mtime: "显示修改时间或循环切换 mtime 排序模式。", + disp_show_mtime_2: "按 mtime 排序时:当前条目、子项中最新、子项中最旧。", + disp_sort_count: "在按条目数降序/升序排序之间切换。", + disp_show_count: "显示或隐藏条目数。", + disp_sort_name: "在按名称升序/降序排序之间切换。", + disp_cycle_bar: "循环切换百分比和条形图显示选项。", + + oms_title: "打开/标记/搜索", + oms_open: "使用关联的程序打开所选条目。", + oms_toggle_down: "切换当前所选条目的标记状态并下移。", + oms_mark_down: "将当前所选条目标记为待删除并下移。", + oms_toggle: "切换当前所选条目的标记状态。", + oms_mark_cleanup: "标记当前视图中的清理候选项。", + oms_toggle_cleanup: "切换清理候选项检测。", + oms_mark_gitignored: "标记当前视图中被 Git 忽略的条目。", + oms_toggle_gitignored: "切换 Git 忽略条目检测。", + oms_toggle_all: "切换所有条目的标记状态。", + oms_search: "Git 风格的 glob 搜索。", + oms_search_2: "从当前目录开始搜索。", + oms_refresh_one: "仅刷新所选条目。", + oms_refresh_all: "刷新当前视图中的所有条目。", + + mark_title: "已标记条目面板", + mark_remove: "从列表中移除所选条目。", + mark_remove_all: "从列表中移除所有条目。", + mark_delete: "不经提示永久删除所有已标记条目。", + mark_delete_2: "此操作无法撤销!", + #[cfg(feature = "trash-move")] + mark_trash: "将所有已标记条目移到回收站。", + #[cfg(feature = "trash-move")] + mark_trash_2: "可以从回收站恢复这些条目。", + + app_title: "应用控制", + app_suspend: "暂停应用程序并将控制权交还给 shell。", + app_repaint: "清空并重绘屏幕。", + app_quit: "直接关闭应用程序,不作确认!", +}; + #[cfg(test)] mod tests { use super::*; @@ -373,6 +440,12 @@ mod tests { assert_eq!(detect([None, None, Some("ko")]), Language::Korean); } + #[test] + fn chinese_locale_selects_chinese_when_codeset_is_missing_or_utf8() { + assert_eq!(detect([None, None, Some("zh_CN.UTF-8")]), Language::Chinese); + assert_eq!(detect([None, None, Some("zh")]), Language::Chinese); + } + #[test] fn explicit_non_utf8_supported_locales_are_english() { assert_eq!( @@ -388,6 +461,10 @@ mod tests { detect([None, None, Some("ko_KR.EUC-KR")]), Language::English ); + assert_eq!( + detect([None, None, Some("zh_CN.GB18030")]), + Language::English + ); } #[test] From 49792768817d3af6b93755430f52a80b0cfb8611 Mon Sep 17 00:00:00 2001 From: Codex GPT-5 Date: Thu, 3 Sep 2026 09:45:49 +0200 Subject: [PATCH 4/6] fix: keep Traditional Chinese locales in English Restrict the Simplified Chinese help table to bare zh, zh_CN, zh_SG, and zh_Hans while retaining English fallback for zh_TW, zh_HK, zh_MO, and zh_Hant. This addresses the post-commit review finding for 7b5e8e48. Validated with: cargo test interactive::widgets::i18n::tests --- README.md | 4 ++-- src/interactive/widgets/i18n.rs | 23 +++++++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b33358c3..ddd27628 100644 --- a/README.md +++ b/README.md @@ -237,8 +237,8 @@ dua interactive The help screen can be localized via the standard POSIX locale environment variables, in the usual order of precedence `LC_ALL` > `LC_MESSAGES` > `LANG`. English is the default; Japanese -(`ja`), Korean (`ko`), and Simplified Chinese (`zh`) are also available when the locale uses UTF-8 -or omits the codeset: +(`ja`), Korean (`ko`), and Simplified Chinese (`zh`, `zh_CN`, `zh_SG`, or `zh_Hans`) are also +available when the locale uses UTF-8 or omits the codeset: ```bash LANG=ja_JP.UTF-8 dua i # then press '?' for the Japanese help screen diff --git a/src/interactive/widgets/i18n.rs b/src/interactive/widgets/i18n.rs index 860c0219..f115e0e0 100644 --- a/src/interactive/widgets/i18n.rs +++ b/src/interactive/widgets/i18n.rs @@ -54,16 +54,16 @@ where .find(|value| !value.as_ref().is_empty()); match locale .as_ref() - .and_then(|locale| utf8_language(locale.as_ref())) + .and_then(|locale| utf8_locale(locale.as_ref())) { - Some("ja") => Language::Japanese, - Some("ko") => Language::Korean, - Some("zh") => Language::Chinese, + Some(("ja", _)) => Language::Japanese, + Some(("ko", _)) => Language::Korean, + Some(("zh", "zh" | "zh_CN" | "zh_SG" | "zh_Hans")) => Language::Chinese, _ => Language::English, } } -fn utf8_language(locale: &str) -> Option<&str> { +fn utf8_locale(locale: &str) -> Option<(&str, &str)> { let locale = locale.split_once('@').map_or(locale, |(locale, _)| locale); let (language_region, codeset) = match locale.split_once('.') { Some((language_region, codeset)) => (language_region, Some(codeset)), @@ -72,7 +72,9 @@ fn utf8_language(locale: &str) -> Option<&str> { let language = language_region .split_once('_') .map_or(language_region, |(language, _)| language); - codeset.is_none_or(is_utf8_codeset).then_some(language) + codeset + .is_none_or(is_utf8_codeset) + .then_some((language, language_region)) } fn is_utf8_codeset(codeset: &str) -> bool { @@ -443,9 +445,18 @@ mod tests { #[test] fn chinese_locale_selects_chinese_when_codeset_is_missing_or_utf8() { assert_eq!(detect([None, None, Some("zh_CN.UTF-8")]), Language::Chinese); + assert_eq!(detect([None, None, Some("zh_SG.UTF8")]), Language::Chinese); + assert_eq!(detect([None, None, Some("zh_Hans")]), Language::Chinese); assert_eq!(detect([None, None, Some("zh")]), Language::Chinese); } + #[test] + fn traditional_chinese_locales_are_english() { + for locale in ["zh_TW.UTF-8", "zh_HK.UTF-8", "zh_MO.UTF-8", "zh_Hant"] { + assert_eq!(detect([None, None, Some(locale)]), Language::English); + } + } + #[test] fn explicit_non_utf8_supported_locales_are_english() { assert_eq!( From 5b5e83f038ede02d8db71011a5168defb44aefac Mon Sep 17 00:00:00 2001 From: Byron Date: Thu, 3 Sep 2026 09:48:30 +0200 Subject: [PATCH 5/6] feat: add German help translation Add de locale detection and a complete German translation for the interactive help pane, with documentation. Regression coverage verifies de_DE.UTF-8 and de selection plus English fallback for de_DE.ISO-8859-1. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- README.md | 10 +++-- src/interactive/widgets/help.rs | 26 ++++++++++- src/interactive/widgets/i18n.rs | 77 +++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ddd27628..e88ca1ef 100644 --- a/README.md +++ b/README.md @@ -236,11 +236,15 @@ dua interactive ``` The help screen can be localized via the standard POSIX locale environment variables, in the -usual order of precedence `LC_ALL` > `LC_MESSAGES` > `LANG`. English is the default; Japanese -(`ja`), Korean (`ko`), and Simplified Chinese (`zh`, `zh_CN`, `zh_SG`, or `zh_Hans`) are also -available when the locale uses UTF-8 or omits the codeset: +usual order of precedence `LC_ALL` > `LC_MESSAGES` > `LANG`. English is the default; German (`de`), +Japanese (`ja`), Korean (`ko`), and Simplified Chinese (`zh`, `zh_CN`, `zh_SG`, or `zh_Hans`) are +also available when the locale uses UTF-8 or omits the codeset: + +Please [open an issue](https://github.com/Byron/dua-cli/issues/new) to request support for your +language, if you would be available for reviewing it as well. ```bash +LANG=de_DE.UTF-8 dua i # then press '?' for the German help screen LANG=ja_JP.UTF-8 dua i # then press '?' for the Japanese help screen LANG=ko_KR.UTF-8 dua i # then press '?' for the Korean help screen LANG=zh_CN.UTF-8 dua i # then press '?' for the Simplified Chinese help screen diff --git a/src/interactive/widgets/help.rs b/src/interactive/widgets/help.rs index fc31b12f..17533dd3 100644 --- a/src/interactive/widgets/help.rs +++ b/src/interactive/widgets/help.rs @@ -292,7 +292,11 @@ mod tests { } fn rendered_with_keys(language: Language, keys: &KeysConfig) -> String { - let area = Rect::new(0, 0, 120, 80); + rendered_at_width(language, keys, 120) + } + + fn rendered_at_width(language: Language, keys: &KeysConfig, width: u16) -> String { + let area = Rect::new(0, 0, width, 80); let mut buf = Buffer::empty(area); HelpPane { language, @@ -340,6 +344,26 @@ mod tests { assert!(ja_collapsed.contains("ナビゲーション")); } + #[test] + fn german_instructions_fit_an_80_column_pane() { + let text = rendered_at_width(Language::German, &KeysConfig::default(), 80); + let german = Language::German.help_text(); + for expected in [ + german.pane_q_quit, + german.pane_tab_2, + german.disp_sort_mtime, + german.disp_show_mtime_2, + german.disp_sort_count, + german.oms_toggle_down, + german.oms_mark_down, + german.oms_mark_gitignored, + ] { + assert!(text.contains(expected), "clipped help text: {expected}"); + } + #[cfg(feature = "trash-move")] + assert!(text.contains(german.mark_trash_2)); + } + #[test] fn configured_keybindings_are_rendered() { let config: dua::Config = toml::from_str( diff --git a/src/interactive/widgets/i18n.rs b/src/interactive/widgets/i18n.rs index f115e0e0..d60cb707 100644 --- a/src/interactive/widgets/i18n.rs +++ b/src/interactive/widgets/i18n.rs @@ -15,6 +15,7 @@ pub enum Language { Japanese, Korean, Chinese, + German, } impl Language { @@ -34,6 +35,7 @@ impl Language { Language::Japanese => &JA, Language::Korean => &KO, Language::Chinese => &ZH, + Language::German => &DE, } } } @@ -56,6 +58,7 @@ where .as_ref() .and_then(|locale| utf8_locale(locale.as_ref())) { + Some(("de", _)) => Language::German, Some(("ja", _)) => Language::Japanese, Some(("ko", _)) => Language::Korean, Some(("zh", "zh" | "zh_CN" | "zh_SG" | "zh_Hans")) => Language::Chinese, @@ -406,6 +409,70 @@ const ZH: HelpText = HelpText { app_quit: "直接关闭应用程序,不作确认!", }; +const DE: HelpText = HelpText { + block_title: "Hilfe", + + pane_control_title: "Bereichssteuerung", + pane_q_quit: "Bereich schließen; Hauptansicht beenden (evtl. bestätigen).", + pane_esc_close: "Aktuellen Bereich schließen.", + pane_esc_close_2: "In der Hauptansicht zum übergeordneten Verzeichnis wechseln.", + pane_qesc_close: "Aktuellen Bereich schließen.", + pane_qesc_close_2: "Beendet das Programm, wenn kein Bereich geöffnet ist.", + pane_tab: "Zwischen allen geöffneten Bereichen wechseln.", + pane_tab_2: "„Markierte Einträge“ zum Löschen gewählter Dateien öffnen.", + pane_help_toggle: "Diesen Hilfebereich ein- oder ausblenden.", + + nav_title: "Navigation", + nav_down: "Einen Eintrag nach unten bewegen.", + nav_up: "Einen Eintrag nach oben bewegen.", + nav_descend: "In das ausgewählte Verzeichnis wechseln.", + nav_ascend: "Eine Ebene ins übergeordnete Verzeichnis wechseln.", + nav_down10: "10 Einträge nach unten bewegen.", + nav_up10: "10 Einträge nach oben bewegen.", + nav_top: "Zum Anfang der Liste springen.", + nav_bottom: "Zum Ende der Liste springen.", + + disp_title: "Anzeige", + disp_sort_size: "Sortierung nach Größe absteigend/aufsteigend umschalten.", + disp_sort_mtime: "Nach Änderungszeit ab-/aufsteigend sortieren.", + disp_show_mtime: "Änderungszeit anzeigen oder mtime-Sortiermodus wechseln.", + disp_show_mtime_2: "mtime-Sortierung: Eintrag, neuester/ältester Untereintrag.", + disp_sort_count: "Nach Eintragsanzahl ab-/aufsteigend sortieren.", + disp_show_count: "Eintragsanzahl ein- oder ausblenden.", + disp_sort_name: "Nach Namen auf-/absteigend sortieren.", + disp_cycle_bar: "Zwischen Prozent- und Balkenanzeige wechseln.", + + oms_title: "Öffnen/Markieren/Suchen", + oms_open: "Ausgewählten Eintrag mit dem zugeordneten Programm öffnen.", + oms_toggle_down: "Markierung umschalten und einen Eintrag nach unten gehen.", + oms_mark_down: "Zum Löschen markieren und einen Eintrag nach unten gehen.", + oms_toggle: "Markierung des ausgewählten Eintrags umschalten.", + oms_mark_cleanup: "Bereinigungskandidaten in der aktuellen Ansicht markieren.", + oms_toggle_cleanup: "Erkennung von Bereinigungskandidaten umschalten.", + oms_mark_gitignored: "Von Git ignorierte Einträge dieser Ansicht markieren.", + oms_toggle_gitignored: "Erkennung von Git-ignorierten Einträgen umschalten.", + oms_toggle_all: "Markierung aller Einträge umschalten.", + oms_search: "Glob-Suche im Git-Stil.", + oms_search_2: "Die Suche beginnt im aktuellen Verzeichnis.", + oms_refresh_one: "Nur den ausgewählten Eintrag aktualisieren.", + oms_refresh_all: "Alle Einträge in der aktuellen Ansicht aktualisieren.", + + mark_title: "Bereich „Markierte Einträge“", + mark_remove: "Ausgewählten Eintrag aus der Liste entfernen.", + mark_remove_all: "Alle Einträge aus der Liste entfernen.", + mark_delete: "Alle markierten Einträge ohne Rückfrage endgültig löschen.", + mark_delete_2: "Dieser Vorgang kann nicht rückgängig gemacht werden!", + #[cfg(feature = "trash-move")] + mark_trash: "Alle markierten Einträge in den Papierkorb verschieben.", + #[cfg(feature = "trash-move")] + mark_trash_2: "Einträge können aus dem Papierkorb wiederhergestellt werden.", + + app_title: "Anwendungssteuerung", + app_suspend: "Anwendung anhalten und Steuerung an die Shell zurückgeben.", + app_repaint: "Bildschirm leeren und neu zeichnen.", + app_quit: "Anwendung ohne Rückfrage schließen!", +}; + #[cfg(test)] mod tests { use super::*; @@ -442,6 +509,12 @@ mod tests { assert_eq!(detect([None, None, Some("ko")]), Language::Korean); } + #[test] + fn german_locale_selects_german_when_codeset_is_missing_or_utf8() { + assert_eq!(detect([None, None, Some("de_DE.UTF-8")]), Language::German); + assert_eq!(detect([None, None, Some("de")]), Language::German); + } + #[test] fn chinese_locale_selects_chinese_when_codeset_is_missing_or_utf8() { assert_eq!(detect([None, None, Some("zh_CN.UTF-8")]), Language::Chinese); @@ -459,6 +532,10 @@ mod tests { #[test] fn explicit_non_utf8_supported_locales_are_english() { + assert_eq!( + detect([None, None, Some("de_DE.ISO-8859-1")]), + Language::English + ); assert_eq!( detect([None, None, Some("ja_JP.SJIS")]), Language::English, From c1f5dda106964c04f4cdb132a26deafeb974727c Mon Sep 17 00:00:00 2001 From: Byron Date: Thu, 3 Sep 2026 10:37:43 +0200 Subject: [PATCH 6/6] feat: localize the interactive interface Translate the remaining interactive panes, status messages, progress text, prompts, errors, and notifications for every supported locale. Detect the session language once and keep application, command, key, path, unit, Git-Glob, and mtime names unchanged, while using compact locale-specific action labels where they fit better. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- README.md | 16 +- src/interactive/app/eventloop.rs | 19 +- src/interactive/app/handlers.rs | 115 ++- src/interactive/app/notification.rs | 33 +- src/interactive/app/state.rs | 5 +- src/interactive/app/terminal.rs | 16 +- .../app/tests/journeys_readonly.rs | 3 +- .../app/tests/journeys_with_writes.rs | 3 + src/interactive/app/tests/unit.rs | 22 +- src/interactive/app/tests/utils.rs | 5 +- src/interactive/widgets/entries.rs | 52 +- src/interactive/widgets/footer.rs | 106 ++- src/interactive/widgets/glob.rs | 59 +- src/interactive/widgets/header.rs | 29 +- src/interactive/widgets/help.rs | 23 +- src/interactive/widgets/i18n.rs | 774 +++++++++++++++++- src/interactive/widgets/main.rs | 28 +- src/interactive/widgets/mark.rs | 65 +- 18 files changed, 1164 insertions(+), 209 deletions(-) diff --git a/README.md b/README.md index e88ca1ef..fb0e3b73 100644 --- a/README.md +++ b/README.md @@ -235,19 +235,19 @@ dua i dua interactive ``` -The help screen can be localized via the standard POSIX locale environment variables, in the -usual order of precedence `LC_ALL` > `LC_MESSAGES` > `LANG`. English is the default; German (`de`), -Japanese (`ja`), Korean (`ko`), and Simplified Chinese (`zh`, `zh_CN`, `zh_SG`, or `zh_Hans`) are -also available when the locale uses UTF-8 or omits the codeset: +The interactive interface can be localized via the standard POSIX locale environment variables, +in the usual order of precedence `LC_ALL` > `LC_MESSAGES` > `LANG`. English is the default; German +(`de`), Japanese (`ja`), Korean (`ko`), and Simplified Chinese (`zh`, `zh_CN`, `zh_SG`, or +`zh_Hans`) are also available when the locale uses UTF-8 or omits the codeset: Please [open an issue](https://github.com/Byron/dua-cli/issues/new) to request support for your language, if you would be available for reviewing it as well. ```bash -LANG=de_DE.UTF-8 dua i # then press '?' for the German help screen -LANG=ja_JP.UTF-8 dua i # then press '?' for the Japanese help screen -LANG=ko_KR.UTF-8 dua i # then press '?' for the Korean help screen -LANG=zh_CN.UTF-8 dua i # then press '?' for the Simplified Chinese help screen +LANG=de_DE.UTF-8 dua i # German interface +LANG=ja_JP.UTF-8 dua i # Japanese interface +LANG=ko_KR.UTF-8 dua i # Korean interface +LANG=zh_CN.UTF-8 dua i # Simplified Chinese interface ``` ### Flame graphs diff --git a/src/interactive/app/eventloop.rs b/src/interactive/app/eventloop.rs index e583a310..0899fad8 100644 --- a/src/interactive/app/eventloop.rs +++ b/src/interactive/app/eventloop.rs @@ -87,7 +87,7 @@ impl AppState { snapshot_export: Option<(PathBuf, Option)>, ) -> Result<()> { if self.read_only { - bail!("Snapshots are read-only"); + bail!(self.language.ui_text().snapshots_read_only); } let bg_traversal = BackgroundTraversal::start( traversal.root_index, @@ -118,7 +118,7 @@ impl AppState { if self.read_only { let path = tree_view.path_of(self.navigation().view_root); if path.as_os_str().is_empty() { - PathBuf::from("") + PathBuf::from(self.language.ui_text().snapshot_label) } else { path } @@ -173,15 +173,15 @@ impl AppState { fn scan_parent(&mut self, tree: &mut TreeView<'_>) -> Result<()> { if self.read_only { - self.message = Some("Snapshots are read-only".into()); + self.message = Some(self.language.ui_text().snapshots_read_only.into()); return Ok(()); } if self.scan.is_some() { - self.message = Some("Traversal already running".into()); + self.message = Some(self.language.ui_text().traversal_running.into()); return Ok(()); } let Some((parent, preexisting, wrap_root)) = self.parent_scan_target(tree) else { - self.message = Some("Top level reached".into()); + self.message = Some(self.language.ui_text().top_level.into()); return Ok(()); }; @@ -477,6 +477,7 @@ impl AppState { traversal, &roots, compression_level, + self.language, )?; } self.scan = None; @@ -486,6 +487,7 @@ impl AppState { self.refresh_screen(window, traversal, display, terminal, config)?; if is_finished { let message = notification::scan_finished( + self.language, self.stats.entries_traversed, self.stats.total_bytes.unwrap_or_default(), self.stats.elapsed.unwrap_or_else(|| self.stats.start.elapsed()), @@ -750,12 +752,12 @@ impl AppState { what: Refresh, ) -> anyhow::Result<()> { if self.read_only { - self.message = Some("Snapshots are read-only".into()); + self.message = Some(self.language.ui_text().snapshots_read_only.into()); return Ok(()); } // If another traversal is already running do not do anything. if self.scan.is_some() { - self.message = Some("Traversal already running".into()); + self.message = Some(self.language.ui_text().traversal_running.into()); return Ok(()); } @@ -890,9 +892,10 @@ impl AppState { self.navigation.view_root, glob_pattern, case, + self.language, ) { Ok(matches) if matches.is_empty() => { - self.message = Some("No match found".into()); + self.message = Some(self.language.ui_text().no_match.into()); } Ok(mut matches) => { if let Some(glob_source) = &self.glob_navigation { diff --git a/src/interactive/app/handlers.rs b/src/interactive/app/handlers.rs index fa969055..b1937cf4 100644 --- a/src/interactive/app/handlers.rs +++ b/src/interactive/app/handlers.rs @@ -36,6 +36,12 @@ pub enum MarkEntryMode { MarkForDeletion, } +#[derive(Clone, Copy)] +enum AnnotationKind { + Cleanup, + Gitignored, +} + /// Aggregate outcome of an entire deletion or trash operation. /// /// This combines the results for all selected entries and adds the operation's @@ -83,12 +89,13 @@ impl AppState { pub fn open_that(&mut self, tree_view: &TreeView<'_>) { if let Some(idx) = self.navigation().selected { let path = tree_view.path_of(idx); + let t = self.language.ui_text(); if self.read_only && !path.exists() { - self.message = Some(format!("Snapshot path is unavailable: {}", path.display())); + self.message = Some(format!("{}{}", t.snapshot_path_unavailable, path.display())); return; } if let Err(err) = open::that(&path) { - self.message = Some(format!("Failed to open {}: {err}", path.display())); + self.message = Some(format!("{}{}: {err}", t.failed_to_open, path.display())); } } } @@ -127,11 +134,9 @@ impl AppState { } None => { self.message = Some(if self.can_scan_parent(tree_view) { - format!( - "Top level reached. Press {scan_parent_key} to scan the parent directory" - ) + self.language.top_level_with_scan(scan_parent_key) } else { - "Top level reached".into() + self.language.ui_text().top_level.into() }); } } @@ -171,7 +176,9 @@ impl AppState { self.update_entry_annotations(tree_view); self.reset_message(); } - None => self.message = Some("Entry is a file or an empty directory".into()), + None => { + self.message = Some(self.language.ui_text().entry_file_or_empty.into()); + } } } } @@ -246,7 +253,12 @@ impl AppState { pub fn toggle_gitignored_entries(&mut self, tree_view: &TreeView<'_>) { if self.read_only { - self.message = Some("Gitignored entry detection is unavailable for snapshots".into()); + self.message = Some( + self.language + .ui_text() + .gitignore_snapshot_unavailable + .into(), + ); return; } self.gitignored_entries = self.gitignored_entries.is_none().then(BTreeSet::new); @@ -274,9 +286,9 @@ impl AppState { pub fn reset_message(&mut self) { if self.scan.is_some() { - self.message = Some("-> scanning <-".into()); + self.message = Some(self.language.ui_text().scanning.into()); } else { - self.message = annotation_message( + self.message = self.language.annotation_message( self.cleanup_candidates.as_ref().map_or(0, BTreeSet::len), self.gitignored_entries.as_ref().map_or(0, BTreeSet::len), ); @@ -286,7 +298,7 @@ impl AppState { pub fn toggle_help_pane(&mut self, window: &mut MainWindow) { self.focussed = match self.focussed { Main | Mark | Glob => { - window.help = Some(HelpPane::with_locale_from_env()); + window.help = Some(HelpPane::default()); Help } Help => { @@ -332,12 +344,12 @@ impl AppState { .and_then(|p| p.process_events(key, &config.keys)); window.mark = match res { Some((pane, Some(_))) if self.read_only => { - self.message = Some("Snapshots are read-only".into()); + self.message = Some(self.language.ui_text().snapshots_read_only.into()); Some(pane) } Some((pane, mode)) => match mode { Some(MarkMode::Delete) => { - self.message = Some("Deleting items...".to_string()); + self.message = Some(self.language.ui_text().deleting_items.into()); let start = Instant::now(); let mut entries_deleted = 0; let mut bytes_deleted = 0; @@ -350,7 +362,8 @@ impl AppState { Ok(stats) => { entries_deleted += stats.entries; bytes_deleted += stats.bytes; - self.message = Some(format!("Deleted {entries_deleted} items...")); + self.message = + Some(self.language.deletion_progress(entries_deleted, false)); Ok(pane) } Err(stats) => { @@ -363,7 +376,7 @@ impl AppState { }); self.message = None; self.notify_deletion_finished( - "Deletion", + self.language.ui_text().notification_deletion, DeletionStats { entries: entries_deleted, bytes: bytes_deleted, @@ -377,7 +390,7 @@ impl AppState { } #[cfg(feature = "trash-move")] Some(MarkMode::Trash) => { - self.message = Some("Trashing items...".to_string()); + self.message = Some(self.language.ui_text().trashing_items.into()); let start = Instant::now(); let mut entries_trashed = 0; let mut bytes_trashed = 0; @@ -394,7 +407,8 @@ impl AppState { Ok(ed) => { entries_trashed += ed; bytes_trashed += entry_size; - self.message = Some(format!("Trashed {entries_trashed} items...")); + self.message = + Some(self.language.deletion_progress(entries_trashed, true)); Ok(pane) } Err(c) => { @@ -405,7 +419,7 @@ impl AppState { }); self.message = None; self.notify_deletion_finished( - "Trash", + self.language.ui_text().notification_trash, DeletionStats { entries: entries_trashed, bytes: bytes_trashed, @@ -434,6 +448,7 @@ impl AppState { config: &Config, ) { let message = notification::deletion_finished( + self.language, action, stats.entries, stats.bytes, @@ -589,13 +604,13 @@ impl AppState { match self.cleanup_candidates.clone() { Some(cleanup_candidates) => self.mark_annotation_candidates( cleanup_candidates, - "No cleanup candidates in view", - "Cleanup candidates are already marked", - "cleanup candidates", + AnnotationKind::Cleanup, window, tree_view, ), - None => self.message = Some("Cleanup candidate detection is disabled".into()), + None => { + self.message = Some(self.language.ui_text().cleanup_detection_disabled.into()); + } } } @@ -603,22 +618,20 @@ impl AppState { match self.gitignored_entries.clone() { Some(gitignored_entries) => self.mark_annotation_candidates( gitignored_entries, - "No gitignored entries in view", - "Gitignored entries are already marked", - "gitignored entries", + AnnotationKind::Gitignored, window, tree_view, ), - None => self.message = Some("Gitignored entry detection is disabled".into()), + None => { + self.message = Some(self.language.ui_text().gitignore_detection_disabled.into()); + } } } fn mark_annotation_candidates( &mut self, annotation_candidates: BTreeSet, - none_in_view_message: &str, - already_marked_message: &str, - marked_label: &str, + kind: AnnotationKind, window: &mut MainWindow, tree_view: &TreeView<'_>, ) { @@ -639,13 +652,22 @@ impl AppState { } if candidates.is_empty() { - self.message = Some(if annotation_candidates.is_empty() { - none_in_view_message.into() - } else { - already_marked_message.into() - }); + let t = self.language.ui_text(); + self.message = Some( + match (kind, annotation_candidates.is_empty()) { + (AnnotationKind::Cleanup, true) => t.no_cleanup_candidates, + (AnnotationKind::Cleanup, false) => t.cleanup_candidates_already_marked, + (AnnotationKind::Gitignored, true) => t.no_gitignored_entries, + (AnnotationKind::Gitignored, false) => t.gitignored_entries_already_marked, + } + .into(), + ); } else { - self.message = Some(format!("Marked {} {marked_label}", candidates.len())); + self.message = + Some(self.language.marked_candidates( + candidates.len(), + matches!(kind, AnnotationKind::Gitignored), + )); } } @@ -672,29 +694,6 @@ impl AppState { } } -fn annotation_message(cleanup_count: usize, gitignored_count: usize) -> Option { - match (cleanup_count, gitignored_count) { - (0, 0) => None, - (cleanup, 0) => { - let label = if cleanup == 1 { - "cleanup candidate" - } else { - "cleanup candidates" - }; - Some(format!("{cleanup} {label}")) - } - (0, gitignored) => { - let label = if gitignored == 1 { - "gitignored entry" - } else { - "gitignored entries" - }; - Some(format!("{gitignored} {label}")) - } - (cleanup, gitignored) => Some(format!("{cleanup} cleanup, {gitignored} gitignored")), - } -} - fn io_err_to_usize(err: io::Error) -> usize { usize::from(err.kind() != io::ErrorKind::NotFound) } diff --git a/src/interactive/app/notification.rs b/src/interactive/app/notification.rs index 71f96d02..f745318b 100644 --- a/src/interactive/app/notification.rs +++ b/src/interactive/app/notification.rs @@ -1,27 +1,28 @@ //! Utilities to produce notifications. use std::{io, time::Duration}; +use crate::interactive::widgets::Language; use dua::ByteFormat; pub fn scan_finished( + language: Language, entries: u64, bytes: u128, elapsed: Duration, errors: u64, format: ByteFormat, ) -> String { - let errors = match errors { - 0 => String::new(), - count => format!(", {count} errors"), - }; - format!( - "Scan finished: {entries} entries, {} in {}{errors}", - format.display(bytes), - duration(elapsed) + language.notification_summary( + language.ui_text().notification_scan, + entries, + &format.display(bytes).to_string(), + &duration(elapsed), + errors, ) } pub fn deletion_finished( + language: Language, action: &str, entries: usize, bytes: u128, @@ -29,14 +30,12 @@ pub fn deletion_finished( errors: usize, format: ByteFormat, ) -> String { - let errors = match errors { - 0 => String::new(), - count => format!(", {count} errors"), - }; - format!( - "{action} finished: {entries} entries, {} in {}{errors}", - format.display(bytes), - duration(elapsed) + language.notification_summary( + action, + u64::try_from(entries).unwrap_or(u64::MAX), + &format.display(bytes).to_string(), + &duration(elapsed), + u64::try_from(errors).unwrap_or(u64::MAX), ) } @@ -105,6 +104,7 @@ mod tests { fn formats_scan_statistics_concisely() { assert_eq!( scan_finished( + Language::English, 42, 2_000_000, Duration::from_millis(1250), @@ -116,6 +116,7 @@ mod tests { assert_eq!( scan_finished( + Language::English, 42, 2_000_000, Duration::from_millis(11250), diff --git a/src/interactive/app/state.rs b/src/interactive/app/state.rs index 0114273a..ce828bc4 100644 --- a/src/interactive/app/state.rs +++ b/src/interactive/app/state.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use dua::WalkOptions; use dua::traverse::{BackgroundTraversal, TraversalStats}; -use crate::interactive::widgets::Column; +use crate::interactive::widgets::{Column, Language}; use super::{EntryDataBundle, SortMode, input::TerminalFocus, navigation::Navigation}; @@ -37,6 +37,8 @@ pub struct FilesystemScan { reason = "independent UI state flags are clearer than an artificial state machine" )] pub struct AppState { + /// Language used for all interactive interface text in this session. + pub language: Language, /// Navigation state for the main traversal view. pub navigation: Navigation, /// Navigation state for an active glob-filtered view, if one is open. @@ -90,6 +92,7 @@ impl AppState { read_only: bool, ) -> Self { AppState { + language: Language::from_env(), navigation: Navigation::default(), glob_navigation: None, entries: vec![], diff --git a/src/interactive/app/terminal.rs b/src/interactive/app/terminal.rs index 5d19893e..df24c19a 100644 --- a/src/interactive/app/terminal.rs +++ b/src/interactive/app/terminal.rs @@ -26,7 +26,7 @@ use dua::{ }; use tui::{Terminal, backend::Backend}; -use crate::interactive::widgets::MainWindow; +use crate::interactive::widgets::{Language, MainWindow}; use super::{DisplayOptions, sorted_entries, state::AppState}; @@ -228,26 +228,24 @@ pub(super) fn write_snapshot_atomically( traversal: &Traversal, roots: &[TreeIndex], compression_level: Option, + language: Language, ) -> Result<()> { + let t = language.ui_text(); let parent = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) .unwrap_or_else(|| Path::new(".")); - let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| { - format!( - "Could not create a temporary snapshot beside {}", - path.display() - ) - })?; + let mut temporary = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("{}{}", t.snapshot_temporary_failed, path.display()))?; dua::snapshot::write(temporary.as_file_mut(), traversal, roots, compression_level) - .with_context(|| format!("Could not write snapshot to {}", path.display()))?; + .with_context(|| format!("{}{}", t.snapshot_write_failed, path.display()))?; temporary.as_file_mut().flush()?; temporary.as_file().sync_all()?; temporary .into_temp_path() .persist(path) .map_err(|err| err.error) - .with_context(|| format!("Could not install snapshot at {}", path.display()))?; + .with_context(|| format!("{}{}", t.snapshot_install_failed, path.display()))?; Ok(()) } diff --git a/src/interactive/app/tests/journeys_readonly.rs b/src/interactive/app/tests/journeys_readonly.rs index 361ed9de..e6edfd3b 100644 --- a/src/interactive/app/tests/journeys_readonly.rs +++ b/src/interactive/app/tests/journeys_readonly.rs @@ -5,7 +5,7 @@ use std::{ffi::OsString, fs, time::Duration}; use tui::backend::Backend; use crate::interactive::app::tests::utils::{into_codes, into_events}; -use crate::interactive::widgets::Column; +use crate::interactive::widgets::{Column, Language}; use crate::interactive::{ MTimeSort, SortMode, app::tests::{ @@ -739,6 +739,7 @@ fn snapshot_roundtrip_is_read_only() -> Result<()> { snapshot.traversal, Some(snapshot_load_duration), )?; + app.state.language = Language::English; assert!(app.state.read_only); assert_eq!(app.state.stats.elapsed, Some(snapshot_load_duration)); diff --git a/src/interactive/app/tests/journeys_with_writes.rs b/src/interactive/app/tests/journeys_with_writes.rs index 6746ca9d..018776a1 100644 --- a/src/interactive/app/tests/journeys_with_writes.rs +++ b/src/interactive/app/tests/journeys_with_writes.rs @@ -3,6 +3,7 @@ use crate::interactive::app::tests::utils::{ new_test_terminal, }; use crate::interactive::terminal::TerminalApp; +use crate::interactive::widgets::Language; use anyhow::Result; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; use dua::{ByteFormat, Config, WalkOptions}; @@ -137,6 +138,7 @@ $precious.tmp dua::traverse::Traversal::new(), None, )?; + app.state.language = Language::English; app.traverse()?; app.run_until_traversed(&mut terminal, key_receive)?; @@ -278,6 +280,7 @@ fn cleanup_candidates_are_marked_with_one_key_after_entering_project_dir() -> Re dua::traverse::Traversal::new(), None, )?; + app.state.language = Language::English; app.traverse()?; app.run_until_traversed(&mut terminal, key_receive)?; diff --git a/src/interactive/app/tests/unit.rs b/src/interactive/app/tests/unit.rs index 71ae4db1..ca8612d5 100644 --- a/src/interactive/app/tests/unit.rs +++ b/src/interactive/app/tests/unit.rs @@ -2,7 +2,7 @@ use crate::interactive::app::tests::utils::{ initialized_app_and_terminal_from_fixture, sample_01_tree, sample_02_tree, }; use crate::interactive::app::{state::AppState, tree_view::TreeView}; -use crate::interactive::widgets::glob_search; +use crate::interactive::widgets::{Language, glob_search}; use crate::interactive::{EntryCheck, MTimeSort, SortMode, sorted_entries}; use anyhow::Result; use dua::WalkOptions; @@ -62,7 +62,14 @@ fn it_can_handle_ending_traversal_without_reaching_the_top() -> Result<()> { #[test] fn it_can_do_a_glob_search() { let (tree, root_index) = sample_02_tree(false); - let result = glob_search(&tree, root_index, "tests/fixtures/sample-02", Case::Fold).unwrap(); + let result = glob_search( + &tree, + root_index, + "tests/fixtures/sample-02", + Case::Fold, + Language::English, + ) + .unwrap(); let expected = vec![TreeIndex::new(1)]; assert_eq!(result, expected); } @@ -70,8 +77,14 @@ fn it_can_do_a_glob_search() { #[test] fn it_can_do_a_case_sensitive_glob_search() { let (tree, root_index) = sample_02_tree(false); - let result_insensitive = - glob_search(&tree, root_index, "TESTS/FIXTURES/SAMPLE-02", Case::Fold).unwrap(); + let result_insensitive = glob_search( + &tree, + root_index, + "TESTS/FIXTURES/SAMPLE-02", + Case::Fold, + Language::English, + ) + .unwrap(); assert_eq!(result_insensitive, vec![TreeIndex::new(1)]); let result_sensitive = glob_search( @@ -79,6 +92,7 @@ fn it_can_do_a_case_sensitive_glob_search() { root_index, "TESTS/FIXTURES/SAMPLE-02", Case::Sensitive, + Language::English, ) .unwrap(); assert!(result_sensitive.is_empty()); diff --git a/src/interactive/app/tests/utils.rs b/src/interactive/app/tests/utils.rs index 4d51e60d..dc2686aa 100644 --- a/src/interactive/app/tests/utils.rs +++ b/src/interactive/app/tests/utils.rs @@ -16,7 +16,7 @@ use std::{ }; use tui::{Terminal, backend::TestBackend}; -use crate::interactive::{app::tests::FIXTURE_PATH, terminal::TerminalApp}; +use crate::interactive::{app::tests::FIXTURE_PATH, terminal::TerminalApp, widgets::Language}; pub fn into_events<'a>(events: impl IntoIterator + 'a) -> Receiver { let (key_send, key_receive) = crossbeam::channel::unbounded(); @@ -209,7 +209,7 @@ pub fn untraversed_app_and_terminal_with_closure( let input_paths = fixture_paths.iter().map(|c| convert(c.as_ref())).collect(); - let app = TerminalApp::initialize( + let mut app = TerminalApp::initialize( &mut terminal, walk_options, ByteFormat::Metric, @@ -220,6 +220,7 @@ pub fn untraversed_app_and_terminal_with_closure( dua::traverse::Traversal::new(), None, )?; + app.state.language = Language::English; Ok((terminal, app)) } diff --git a/src/interactive/widgets/entries.rs b/src/interactive/widgets/entries.rs index 6555fae6..4435a20d 100644 --- a/src/interactive/widgets/entries.rs +++ b/src/interactive/widgets/entries.rs @@ -6,7 +6,7 @@ use crate::interactive::widgets::tui_ext::{ }; use crate::interactive::{ DisplayOptions, EntryDataBundle, SortMode, - widgets::{EntryMarkMap, entry_color}, + widgets::{EntryMarkMap, Language, entry_color}, }; use chrono::DateTime; use dua::traverse::TreeIndex; @@ -51,6 +51,8 @@ pub struct EntriesProps<'a> { pub show_columns: &'a HashSet, /// Configured keyboard shortcuts shown in the pane hints. pub keys: &'a dua::KeysConfig, + /// Language used for pane titles and action hints. + pub language: Language, } #[derive(Default)] @@ -78,6 +80,7 @@ impl Entries { sort_mode, show_columns, keys, + language, } = props.borrow(); let list = &mut self.list; @@ -95,6 +98,7 @@ impl Entries { *display, item_size, title_width_inside_borders, + *language, ); let title_block = title_block(&title, *border_style); let inner_area = title_block.inner(area); @@ -180,7 +184,7 @@ impl Entries { if *is_focussed { let bound = draw_top_right_help(area, &title, buf, keys); - draw_bottom_right_help(bound, buf, keys); + draw_bottom_right_help(bound, buf, keys, *language); } } } @@ -212,11 +216,12 @@ fn title( display: DisplayOptions, size: u128, title_width_cells: usize, + language: Language, ) -> String { - let statistics = format!( - "({item_count} visible, {} total, {})", - COUNT.format(recursive_item_count as f64), - display.byte_format.display(size) + let statistics = language.entries_statistics( + item_count, + &COUNT.format(recursive_item_count as f64), + &display.byte_format.display(size).to_string(), ); let title = format!(" {path} {statistics} ", path = current_path.display()); if title.width() <= title_width_cells { @@ -436,14 +441,25 @@ impl<'a> DisplayPath<'a> { } } -fn draw_bottom_right_help(bound: Rect, buf: &mut Buffer, keys: &dua::KeysConfig) { +fn draw_bottom_right_help( + bound: Rect, + buf: &mut Buffer, + keys: &dua::KeysConfig, + language: Language, +) { let bound = line_bound(bound, bound.height.saturating_sub(1) as usize); + let t = language.ui_text(); let help_text = format!( - " mark-move = {} | mark-toggle = {} | cleanup = {} | gitignore = {} | all = {} ", + " {} = {} | {} = {} | {} = {} | {} = {} | {} = {} ", + t.entries_mark_move, keys.toggle_mark_and_move_down, + t.entries_mark_toggle, keys.toggle_mark, + t.entries_cleanup, keys.mark_cleanup, + t.entries_gitignore, keys.mark_gitignore, + t.entries_all, keys.toggle_all, ); @@ -761,7 +777,7 @@ mod entries_test { use std::path::Path; use super::{name_style, shorten_input, show_mtime_column, title as entry_title}; - use crate::interactive::widgets::Column; + use crate::interactive::widgets::{Column, Language}; use crate::interactive::{MTimeSort, SortMode}; use dua::ByteFormat; use tui::style::{Color, Modifier, Style}; @@ -809,6 +825,23 @@ mod entries_test { ); } + #[test] + fn title_statistics_follow_the_selected_language() { + let display = crate::interactive::DisplayOptions::new(ByteFormat::Metric); + assert_eq!( + entry_title( + Path::new("项目/资料"), + 4, + 43, + display, + 1_420_000_000, + 80, + Language::Chinese, + ), + " 项目/资料 (显示 4 项,共 43 项,1.42 GB) " + ); + } + #[test] fn title_drops_statistics_for_wide_unicode_path() { assert_eq!(title("项目/资料", 41), " 项目/资料 "); @@ -865,6 +898,7 @@ mod entries_test { display, 1_420_000_000, title_width_cells, + Language::English, ) } diff --git a/src/interactive/widgets/footer.rs b/src/interactive/widgets/footer.rs index ada7a9f8..45f2975b 100644 --- a/src/interactive/widgets/footer.rs +++ b/src/interactive/widgets/footer.rs @@ -9,7 +9,7 @@ use tui::{ widgets::{Paragraph, Widget}, }; -use crate::interactive::{MTimeSort, SortMode}; +use crate::interactive::{MTimeSort, SortMode, widgets::Language}; pub struct Footer; @@ -21,6 +21,7 @@ pub struct FooterProps<'a> { pub sort_mode: SortMode, pub pending_exit: bool, pub keys: &'a dua::KeysConfig, + pub language: Language, } impl Footer { @@ -33,16 +34,16 @@ impl Footer { sort_mode, pending_exit, keys, + language, } = props.borrow(); if *pending_exit { + let quit = keys.quit.to_string(); let exit_msg = if keys.esc_navigates_back { - format!("Press {} again to exit...", keys.quit) + language.exit_message(&quit, None) } else { - format!( - "Press {} or {} again to exit...", - keys.close_pane, keys.quit - ) + let close = keys.close_pane.to_string(); + language.exit_message(&quit, Some(&close)) }; Paragraph::new(Text::from(exit_msg)) .style( @@ -55,17 +56,20 @@ impl Footer { return; } + let t = language.ui_text(); let spans = vec![ Some(Span::from(format!( - "Sort mode: {} Total disk usage: {} ", - sort_mode_label(*sort_mode), + "{}: {} {}: {} ", + t.footer_sort_mode, + sort_mode_label(*sort_mode, *language), + t.footer_total_disk_usage, format.display(*total_bytes), ))), traversal_stats .as_ref() .map(|(entries_traversed, traversal_start, elapsed)| { let progress = if let Some(elapsed) = elapsed { - format!("in {:.02}s", elapsed.as_secs_f32()) + format!("{:.02}s", elapsed.as_secs_f32()) } else { let elapsed = traversal_start.elapsed(); let rate = if elapsed.is_zero() { @@ -73,11 +77,9 @@ impl Footer { } else { *entries_traversed as f32 / elapsed.as_secs_f32() }; - format!("in {:.0}s ({:.0}/s)", elapsed.as_secs_f32(), rate) + format!("{:.0}s ({:.0}/s)", elapsed.as_secs_f32(), rate) }; - Span::from(format!( - "Processed {entries_traversed} entries {progress} " - )) + Span::from(language.footer_progress(*entries_traversed, &progress)) }), message.as_ref().map(|m| { Span::styled( @@ -99,39 +101,41 @@ impl Footer { } } -fn sort_mode_label(sort_mode: SortMode) -> String { +fn sort_mode_label(sort_mode: SortMode, language: Language) -> String { use SortMode::*; + let t = language.ui_text(); match sort_mode { - SizeAscending => "size, small first".into(), - SizeDescending => "size, large first".into(), - MTimeAscending(sort) => modified_sort_label("old first", sort), - MTimeDescending(sort) => modified_sort_label("new first", sort), - CountAscending => "items, few first".into(), - CountDescending => "items, most first".into(), - NameAscending => "name, A-Z".into(), - NameDescending => "name, Z-A".into(), + SizeAscending => t.sort_size_ascending.into(), + SizeDescending => t.sort_size_descending.into(), + MTimeAscending(sort) => modified_sort_label(t.sort_mtime_ascending, sort, language), + MTimeDescending(sort) => modified_sort_label(t.sort_mtime_descending, sort, language), + CountAscending => t.sort_count_ascending.into(), + CountDescending => t.sort_count_descending.into(), + NameAscending => t.sort_name_ascending.into(), + NameDescending => t.sort_name_descending.into(), } } -fn modified_sort_label(order: &'static str, mtime_sort: MTimeSort) -> String { - match mtime_sort_label(mtime_sort) { - Some(label) => format!("mtime, {order} ({label})"), - None => format!("mtime, {order}"), +fn modified_sort_label(label: &str, mtime_sort: MTimeSort, language: Language) -> String { + match mtime_sort_label(mtime_sort, language) { + Some(deep_label) => format!("{label} ({deep_label})"), + None => label.into(), } } -fn mtime_sort_label(mtime_sort: MTimeSort) -> Option<&'static str> { +fn mtime_sort_label(mtime_sort: MTimeSort, language: Language) -> Option<&'static str> { + let t = language.ui_text(); match mtime_sort { MTimeSort::Entry => None, - MTimeSort::RecursiveChildrenNewest => Some("deep newest"), - MTimeSort::RecursiveChildrenOldest => Some("deep oldest"), + MTimeSort::RecursiveChildrenNewest => Some(t.sort_deep_newest), + MTimeSort::RecursiveChildrenOldest => Some(t.sort_deep_oldest), } } #[cfg(test)] mod tests { use super::{Footer, FooterProps, sort_mode_label}; - use crate::interactive::{MTimeSort, SortMode}; + use crate::interactive::{MTimeSort, SortMode, widgets::Language}; use dua::{ByteFormat, KeysConfig}; use std::time::{Duration, Instant}; use tui::{ @@ -139,7 +143,7 @@ mod tests { layout::Rect, }; - fn rendered_footer(show_traversal_stats: bool) -> String { + fn rendered_footer(show_traversal_stats: bool, language: Language) -> String { let area = Rect::new(0, 0, 160, 1); let mut buffer = Buffer::empty(area); Footer::render( @@ -152,6 +156,7 @@ mod tests { sort_mode: SortMode::SizeDescending, pending_exit: false, keys: &KeysConfig::default(), + language, }, area, &mut buffer, @@ -161,8 +166,8 @@ mod tests { #[test] fn completed_traversal_stats_can_be_hidden() { - let with_stats = rendered_footer(true); - let without_stats = rendered_footer(false); + let with_stats = rendered_footer(true, Language::English); + let without_stats = rendered_footer(false, Language::English); insta::assert_debug_snapshot!( (with_stats.trim_end(), without_stats.trim_end()), "completed traversal statistics shown, then hidden", @@ -175,20 +180,36 @@ mod tests { ); } + #[test] + fn footer_uses_the_selected_language() { + let rendered = rendered_footer(true, Language::German); + assert!(rendered.contains("Sortierung: Größe, groß zuerst")); + assert!(rendered.contains("Gesamte Speicherbelegung")); + assert!(rendered.contains("7 Einträge in 0.23s verarbeitet")); + assert!(!rendered.contains("Sort mode")); + } + #[test] fn modified_sort_label_includes_effective_mtime_mode() { assert_eq!( - sort_mode_label(SortMode::MTimeDescending(MTimeSort::Entry)), + sort_mode_label( + SortMode::MTimeDescending(MTimeSort::Entry), + Language::English + ), "mtime, new first" ); assert_eq!( - sort_mode_label(SortMode::MTimeDescending( - MTimeSort::RecursiveChildrenNewest - )), + sort_mode_label( + SortMode::MTimeDescending(MTimeSort::RecursiveChildrenNewest), + Language::English + ), "mtime, new first (deep newest)" ); assert_eq!( - sort_mode_label(SortMode::MTimeAscending(MTimeSort::RecursiveChildrenOldest)), + sort_mode_label( + SortMode::MTimeAscending(MTimeSort::RecursiveChildrenOldest), + Language::English + ), "mtime, old first (deep oldest)" ); } @@ -196,13 +217,16 @@ mod tests { #[test] fn non_modified_sort_labels_describe_what_comes_first() { assert_eq!( - sort_mode_label(SortMode::SizeDescending), + sort_mode_label(SortMode::SizeDescending, Language::English), "size, large first" ); assert_eq!( - sort_mode_label(SortMode::CountAscending), + sort_mode_label(SortMode::CountAscending, Language::English), "items, few first" ); - assert_eq!(sort_mode_label(SortMode::NameAscending), "name, A-Z"); + assert_eq!( + sort_mode_label(SortMode::NameAscending, Language::English), + "name, A-Z" + ); } } diff --git a/src/interactive/widgets/glob.rs b/src/interactive/widgets/glob.rs index 660ac773..07fcc298 100644 --- a/src/interactive/widgets/glob.rs +++ b/src/interactive/widgets/glob.rs @@ -1,3 +1,4 @@ +use crate::interactive::widgets::Language; use crate::interactive::widgets::tui_ext::{ draw_text_nowrap_fn, util::{block_width, rect}, @@ -27,6 +28,7 @@ pub struct GlobPaneProps<'a> { pub border_style: Style, pub has_focus: bool, pub keys: &'a KeysConfig, + pub language: Language, } pub struct GlobPane { @@ -124,11 +126,13 @@ impl GlobPane { border_style, has_focus, keys, + language, } = props.borrow(); + let t = language.ui_text(); let title = match self.case { - Case::Sensitive => "Git-Glob (case-sensitive)", - Case::Fold => "Git-Glob (case-insensitive)", + Case::Sensitive => t.glob_case_sensitive, + Case::Fold => t.glob_case_insensitive, }; let block = Block::default() @@ -144,7 +148,7 @@ impl GlobPane { .render(margin_left_right(inner_block_area, 1), buffer); if *has_focus { - draw_top_right_help(area, title, buffer, keys); + draw_top_right_help(area, title, buffer, keys, *language); cursor.show = true; cursor.x = inner_block_area.x @@ -162,10 +166,22 @@ impl GlobPane { } } -fn draw_top_right_help(area: Rect, title: &str, buf: &mut Buffer, keys: &KeysConfig) -> Rect { +fn draw_top_right_help( + area: Rect, + title: &str, + buf: &mut Buffer, + keys: &KeysConfig, + language: Language, +) -> Rect { + let t = language.ui_text(); let help_text = format!( - " search = {} | case = {} | cancel = {} ", - keys.search_confirm, keys.search_toggle_case, keys.close_pane + " {} = {} | {} = {} | {} = {} ", + t.glob_search, + keys.search_confirm, + t.glob_case, + keys.search_toggle_case, + t.glob_cancel, + keys.close_pane ); let help_text_block_width = block_width(&help_text); let bound = Rect { @@ -231,9 +247,10 @@ pub fn glob_search( root_index: TreeIndex, glob: &str, case: gix::glob::pattern::Case, + language: Language, ) -> Result> { let glob = gix::glob::Pattern::from_bytes_without_negation(glob.as_bytes()) - .with_context(|| anyhow!("Glob was empty or only whitespace"))?; + .with_context(|| anyhow!(language.ui_text().glob_empty))?; let mut results = Vec::new(); let mut path = BString::default(); glob_search_neighbours(&mut results, tree, root_index, &glob, &mut path, case); @@ -244,6 +261,7 @@ pub fn glob_search( mod tests { use super::*; use crossterm::event::{KeyCode, KeyEventKind, KeyEventState, KeyModifiers}; + use tui::buffer::Cell; #[test] fn default_toggle_case_key_does_not_type_into_input() { @@ -320,6 +338,7 @@ mod tests { border_style: Style::default(), has_focus: true, keys: &keys, + language: Language::English, }, area, &mut buffer, @@ -344,4 +363,30 @@ mod tests { "# ); } + + #[test] + fn pane_title_and_actions_follow_the_selected_language() { + let area = Rect::new(0, 0, 100, 3); + let mut buffer = Buffer::empty(area); + + GlobPane::default().render( + GlobPaneProps { + border_style: Style::default(), + has_focus: true, + keys: &KeysConfig::default(), + language: Language::Chinese, + }, + area, + &mut buffer, + &mut Cursor::default(), + ); + + let rendered: String = buffer.content.iter().map(Cell::symbol).collect(); + let rendered: String = rendered.split_whitespace().collect(); + assert!(rendered.contains("Git-Glob")); + assert!(rendered.contains("不区分大小写")); + assert!(rendered.contains("搜索")); + assert!(rendered.contains("取消")); + assert!(!rendered.contains("case-insensitive")); + } } diff --git a/src/interactive/widgets/header.rs b/src/interactive/widgets/header.rs index 7a636706..4d87e7b9 100644 --- a/src/interactive/widgets/header.rs +++ b/src/interactive/widgets/header.rs @@ -6,10 +6,13 @@ use tui::{ widgets::{Paragraph, Widget}, }; +use super::Language; + pub struct Header; impl Header { - pub fn render(bg_color: Color, area: Rect, buf: &mut Buffer) { + pub fn render(language: Language, bg_color: Color, area: Rect, buf: &mut Buffer) { + let t = language.ui_text(); let standard = Style { fg: Color::Black.into(), bg: bg_color.into(), @@ -38,9 +41,9 @@ impl Header { text("nalyzer v"), text(env!("CARGO_PKG_VERSION")), text(" "), - italic("(press "), + italic(t.header_help_before_key), modified("?", Modifier::BOLD | Modifier::UNDERLINED), - italic(" for help)"), + italic(t.header_help_after_key), ]; Paragraph::new(Text::from(Line::from(spans))) .style(Style { @@ -50,3 +53,23 @@ impl Header { .render(area, buf); } } + +#[cfg(test)] +mod tests { + use super::*; + use tui::buffer::Cell; + + #[test] + fn localizes_only_the_help_prompt() { + let area = Rect::new(0, 0, 80, 1); + let mut buffer = Buffer::empty(area); + Header::render(Language::Chinese, Color::White, area, &mut buffer); + let rendered: String = buffer.content.iter().map(Cell::symbol).collect(); + let rendered: String = rendered.split_whitespace().collect(); + + assert!(rendered.contains("DiskUsageAnalyzer")); + assert!(rendered.contains("按")); + assert!(rendered.contains("查看帮助")); + assert!(!rendered.contains("press")); + } +} diff --git a/src/interactive/widgets/help.rs b/src/interactive/widgets/help.rs index 17533dd3..780a4cb0 100644 --- a/src/interactive/widgets/help.rs +++ b/src/interactive/widgets/help.rs @@ -18,13 +18,13 @@ use tui::{ #[derive(Default, Clone)] pub struct HelpPane { pub scroll: u16, - pub language: Language, } pub struct HelpPaneProps<'a> { pub border_style: Style, pub has_focus: bool, pub keys: &'a KeysConfig, + pub language: Language, } fn margin(r: Rect, margin: u16) -> Rect { @@ -37,13 +37,6 @@ fn margin(r: Rect, margin: u16) -> Rect { } impl HelpPane { - pub fn with_locale_from_env() -> Self { - HelpPane { - language: Language::from_env(), - ..Default::default() - } - } - pub fn process_events(&mut self, key: KeyEvent, keys: &KeysConfig) { if key.kind == KeyEventKind::Release { return; @@ -82,7 +75,7 @@ impl HelpPane { ) { let props = props.borrow(); let keys = props.keys; - let t = self.language.help_text(); + let t = props.language.help_text(); let build_lines = || { let lines = RefCell::new(Vec::>::with_capacity(30)); let add_newlines = |n| { @@ -298,15 +291,12 @@ mod tests { fn rendered_at_width(language: Language, keys: &KeysConfig, width: u16) -> String { let area = Rect::new(0, 0, width, 80); let mut buf = Buffer::empty(area); - HelpPane { - language, - ..Default::default() - } - .render( + HelpPane::default().render( HelpPaneProps { border_style: Style::default(), has_focus: false, keys, + language, }, area, &mut buf, @@ -413,10 +403,7 @@ mod tests { ('5', 49), ('6', 51), ] { - let mut pane = HelpPane { - scroll: 50, - ..Default::default() - }; + let mut pane = HelpPane { scroll: 50 }; pane.process_events(KeyCode::Char(key).into(), &config.keys); assert_eq!(pane.scroll, expected, "configured binding {key}"); } diff --git a/src/interactive/widgets/i18n.rs b/src/interactive/widgets/i18n.rs index d60cb707..f8f0b1af 100644 --- a/src/interactive/widgets/i18n.rs +++ b/src/interactive/widgets/i18n.rs @@ -1,13 +1,12 @@ -//! Optional localization for the interactive help pane. +//! Optional localization for the interactive interface. //! //! The language is selected from the standard POSIX locale environment //! variables, honouring their conventional precedence //! `LC_ALL` > `LC_MESSAGES` > `LANG`. English is the default and is used //! whenever no supported language is detected, or when the supported language -//! explicitly requests a non-UTF-8 codeset. Only the help pane is translated; -//! no extra dependencies are involved. +//! explicitly requests a non-UTF-8 codeset. No extra dependencies are involved. -/// A language the help pane can be rendered in. +/// A language the interactive interface can be rendered in. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Language { #[default] @@ -38,6 +37,17 @@ impl Language { Language::German => &DE, } } + + /// The translated strings used by the rest of the interactive interface. + pub fn ui_text(self) -> &'static UiText { + match self { + Language::English => &EN_UI, + Language::Japanese => &JA_UI, + Language::Korean => &KO_UI, + Language::Chinese => &ZH_UI, + Language::German => &DE_UI, + } + } } /// Resolve a [`Language`] from POSIX locale values, most-significant first. @@ -473,6 +483,622 @@ const DE: HelpText = HelpText { app_quit: "Anwendung ohne Rückfrage schließen!", }; +/// Static text used outside the help pane. +pub struct UiText { + pub header_help_before_key: &'static str, + pub header_help_after_key: &'static str, + + pub entries_mark_move: &'static str, + pub entries_mark_toggle: &'static str, + pub entries_cleanup: &'static str, + pub entries_gitignore: &'static str, + pub entries_all: &'static str, + + pub glob_case_sensitive: &'static str, + pub glob_case_insensitive: &'static str, + pub glob_search: &'static str, + pub glob_case: &'static str, + pub glob_cancel: &'static str, + pub glob_empty: &'static str, + + pub mark_snapshot_read_only: &'static str, + pub mark_no_destructive_keys: &'static str, + #[cfg(feature = "trash-move")] + pub mark_to_trash_or: &'static str, + pub mark_to_delete: &'static str, + pub mark_toggle: &'static str, + pub mark_remove_all: &'static str, + + pub footer_sort_mode: &'static str, + pub footer_total_disk_usage: &'static str, + pub sort_size_ascending: &'static str, + pub sort_size_descending: &'static str, + pub sort_mtime_ascending: &'static str, + pub sort_mtime_descending: &'static str, + pub sort_count_ascending: &'static str, + pub sort_count_descending: &'static str, + pub sort_name_ascending: &'static str, + pub sort_name_descending: &'static str, + pub sort_deep_newest: &'static str, + pub sort_deep_oldest: &'static str, + + pub snapshot_label: &'static str, + pub snapshot_path_unavailable: &'static str, + pub snapshot_temporary_failed: &'static str, + pub snapshot_write_failed: &'static str, + pub snapshot_install_failed: &'static str, + pub failed_to_open: &'static str, + pub top_level: &'static str, + pub entry_file_or_empty: &'static str, + pub gitignore_snapshot_unavailable: &'static str, + pub scanning: &'static str, + pub snapshots_read_only: &'static str, + pub traversal_running: &'static str, + pub deleting_items: &'static str, + #[cfg(feature = "trash-move")] + pub trashing_items: &'static str, + pub no_cleanup_candidates: &'static str, + pub cleanup_candidates_already_marked: &'static str, + pub cleanup_detection_disabled: &'static str, + pub no_gitignored_entries: &'static str, + pub gitignored_entries_already_marked: &'static str, + pub gitignore_detection_disabled: &'static str, + pub no_match: &'static str, + + pub notification_scan: &'static str, + pub notification_deletion: &'static str, + #[cfg(feature = "trash-move")] + pub notification_trash: &'static str, + pub notification_finished: &'static str, +} + +impl Language { + pub fn entries_statistics(self, visible: usize, total: &str, size: &str) -> String { + match self { + Language::English => format!("({visible} visible, {total} total, {size})"), + Language::Japanese => format!("(表示 {visible} 件、合計 {total} 件、{size})"), + Language::Korean => format!("(표시 {visible}개, 전체 {total}개, {size})"), + Language::Chinese => format!("(显示 {visible} 项,共 {total} 项,{size})"), + Language::German => format!("({visible} sichtbar, {total} gesamt, {size})"), + } + } + + pub fn marked_title(self, count: &str, size: &str, percentage: f64, root_size: &str) -> String { + match self { + Language::English => { + format!("Marked {count} items ({size}, {percentage:.2}% of {root_size}) ") + } + Language::Japanese => { + format!("マーク済み {count} 件 ({size}、{root_size} の {percentage:.2}%) ") + } + Language::Korean => { + format!("표시된 항목 {count}개 ({size}, {root_size}의 {percentage:.2}%) ") + } + Language::Chinese => { + format!("已标记 {count} 个条目({size},占 {root_size} 的 {percentage:.2}%) ") + } + Language::German => { + format!("{count} Einträge markiert ({size}, {percentage:.2}% von {root_size}) ") + } + } + } + + pub fn deletion_errors(self, count: usize) -> String { + match self { + Language::English => format!("{count} IO deletion errors"), + Language::Japanese => format!("削除 I/O エラー {count} 件"), + Language::Korean => format!("I/O 삭제 오류 {count}건"), + Language::Chinese => format!("{count} 个 I/O 删除错误"), + Language::German => format!("{count} E/A-Löschfehler"), + } + } + + pub fn exit_message(self, quit: &str, close: Option<&str>) -> String { + match (self, close) { + (Language::English, None) => format!("Press {quit} again to exit..."), + (Language::English, Some(close)) => { + format!("Press {close} or {quit} again to exit...") + } + (Language::Japanese, None) => format!("{quit} をもう一度押すと終了します..."), + (Language::Japanese, Some(close)) => { + format!("{close} または {quit} をもう一度押すと終了します...") + } + (Language::Korean, None) => format!("종료하려면 {quit} 키를 다시 누르세요..."), + (Language::Korean, Some(close)) => { + format!("종료하려면 {close} 또는 {quit} 키를 다시 누르세요...") + } + (Language::Chinese, None) => format!("再次按 {quit} 退出..."), + (Language::Chinese, Some(close)) => { + format!("再次按 {close} 或 {quit} 退出...") + } + (Language::German, None) => format!("Zum Beenden {quit} erneut drücken..."), + (Language::German, Some(close)) => { + format!("Zum Beenden {close} oder {quit} erneut drücken...") + } + } + } + + pub fn footer_progress(self, entries: u64, progress: &str) -> String { + match self { + Language::English => format!("Processed {entries} entries in {progress} "), + Language::Japanese => format!("{entries} 件を {progress} で処理 "), + Language::Korean => format!("{entries}개 항목을 {progress} 동안 처리 "), + Language::Chinese => format!("已处理 {entries} 个条目,用时 {progress} "), + Language::German => { + let label = if entries == 1 { "Eintrag" } else { "Einträge" }; + format!("{entries} {label} in {progress} verarbeitet ") + } + } + } + + pub fn top_level_with_scan(self, key: &str) -> String { + match self { + Language::English => { + format!("Top level reached. Press {key} to scan the parent directory") + } + Language::Japanese => { + format!("最上位に到達しました。{key} で親ディレクトリをスキャンします") + } + Language::Korean => { + format!("최상위에 도달했습니다. {key} 키로 상위 디렉터리를 스캔하세요") + } + Language::Chinese => format!("已到达顶层。按 {key} 扫描上级目录"), + Language::German => { + format!("Oberste Ebene erreicht. Mit {key} das übergeordnete Verzeichnis scannen") + } + } + } + + pub fn deletion_progress(self, count: usize, trash: bool) -> String { + match (self, trash) { + (Language::English, false) => format!("Deleted {count} items..."), + (Language::English, true) => format!("Trashed {count} items..."), + (Language::Japanese, false) => format!("{count} 件を削除..."), + (Language::Japanese, true) => format!("{count} 件をゴミ箱へ移動..."), + (Language::Korean, false) => format!("{count}개 항목 삭제..."), + (Language::Korean, true) => format!("{count}개 항목을 휴지통으로 이동..."), + (Language::Chinese, false) => format!("已删除 {count} 个条目..."), + (Language::Chinese, true) => format!("已将 {count} 个条目移至回收站..."), + (Language::German, false) => format!( + "{count} {} gelöscht...", + if count == 1 { "Eintrag" } else { "Einträge" } + ), + (Language::German, true) => { + let label = if count == 1 { "Eintrag" } else { "Einträge" }; + format!("{count} {label} in den Papierkorb verschoben...") + } + } + } + + pub fn marked_candidates(self, count: usize, gitignored: bool) -> String { + match (self, gitignored) { + (Language::English, false) => format!("Marked {count} cleanup candidates"), + (Language::English, true) => format!("Marked {count} gitignored entries"), + (Language::Japanese, false) => { + format!("クリーンアップ候補を {count} 件マーク") + } + (Language::Japanese, true) => format!("Git 無視エントリを {count} 件マーク"), + (Language::Korean, false) => format!("정리 후보 {count}개 표시"), + (Language::Korean, true) => format!("Git 무시 항목 {count}개 표시"), + (Language::Chinese, false) => format!("已标记 {count} 个清理候选项"), + (Language::Chinese, true) => format!("已标记 {count} 个 Git 忽略条目"), + (Language::German, false) => { + let label = if count == 1 { + "Bereinigungskandidat" + } else { + "Bereinigungskandidaten" + }; + format!("{count} {label} markiert") + } + (Language::German, true) => { + let label = if count == 1 { "Eintrag" } else { "Einträge" }; + format!("{count} von Git ignorierte {label} markiert") + } + } + } + + pub fn annotation_message(self, cleanup: usize, gitignored: usize) -> Option { + match (self, cleanup, gitignored) { + (_, 0, 0) => None, + (Language::English, cleanup, 0) => Some(format!( + "{cleanup} cleanup candidate{}", + if cleanup == 1 { "" } else { "s" } + )), + (Language::English, 0, gitignored) => Some(format!( + "{gitignored} gitignored {}", + if gitignored == 1 { "entry" } else { "entries" } + )), + (Language::English, cleanup, gitignored) => { + Some(format!("{cleanup} cleanup, {gitignored} gitignored")) + } + (Language::Japanese, cleanup, 0) => Some(format!("クリーンアップ候補 {cleanup} 件")), + (Language::Japanese, 0, gitignored) => { + Some(format!("Git 無視エントリ {gitignored} 件")) + } + (Language::Japanese, cleanup, gitignored) => Some(format!( + "クリーンアップ {cleanup} 件、Git 無視 {gitignored} 件" + )), + (Language::Korean, cleanup, 0) => Some(format!("정리 후보 {cleanup}개")), + (Language::Korean, 0, gitignored) => Some(format!("Git 무시 항목 {gitignored}개")), + (Language::Korean, cleanup, gitignored) => { + Some(format!("정리 {cleanup}개, Git 무시 {gitignored}개")) + } + (Language::Chinese, cleanup, 0) => Some(format!("{cleanup} 个清理候选项")), + (Language::Chinese, 0, gitignored) => Some(format!("{gitignored} 个 Git 忽略条目")), + (Language::Chinese, cleanup, gitignored) => { + Some(format!("清理 {cleanup} 个,Git 忽略 {gitignored} 个")) + } + (Language::German, cleanup, 0) => Some(format!( + "{cleanup} Bereinigungskandidat{}", + if cleanup == 1 { "" } else { "en" } + )), + (Language::German, 0, gitignored) => Some(format!( + "{gitignored} von Git ignorierter Eintrag{}", + if gitignored == 1 { "" } else { "e" } + )), + (Language::German, cleanup, gitignored) => { + Some(format!("{cleanup} Bereinigung, {gitignored} Git-ignoriert")) + } + } + } + + pub fn notification_summary( + self, + action: &str, + entries: u64, + bytes: &str, + elapsed: &str, + errors: u64, + ) -> String { + let errors = match (self, errors) { + (_, 0) => String::new(), + (Language::English, count) => format!(", {count} errors"), + (Language::Japanese, count) => format!("、エラー {count} 件"), + (Language::Korean, count) => format!(", 오류 {count}건"), + (Language::Chinese, count) => format!(",{count} 个错误"), + (Language::German, count) => format!(", {count} Fehler"), + }; + let t = self.ui_text(); + match self { + Language::English => { + format!( + "{action} {}: {entries} entries, {bytes} in {elapsed}{errors}", + t.notification_finished + ) + } + Language::Japanese => format!( + "{action}{}: {entries} 件、{bytes}、所要時間 {elapsed}{errors}", + t.notification_finished + ), + Language::Korean => format!( + "{action} {}: {entries}개 항목, {bytes}, 소요 시간 {elapsed}{errors}", + t.notification_finished + ), + Language::Chinese => format!( + "{action}{}:{entries} 个条目,{bytes},用时 {elapsed}{errors}", + t.notification_finished + ), + Language::German => { + let label = if entries == 1 { "Eintrag" } else { "Einträge" }; + format!( + "{action} {}: {entries} {label}, {bytes} in {elapsed}{errors}", + t.notification_finished + ) + } + } + } +} + +const EN_UI: UiText = UiText { + header_help_before_key: "(press ", + header_help_after_key: " for help)", + entries_mark_move: "mark-move", + entries_mark_toggle: "mark-toggle", + entries_cleanup: "cleanup", + entries_gitignore: "gitignore", + entries_all: "all", + glob_case_sensitive: "Git-Glob (case-sensitive)", + glob_case_insensitive: "Git-Glob (case-insensitive)", + glob_search: "search", + glob_case: "case", + glob_cancel: "cancel", + glob_empty: "Glob was empty or only whitespace", + mark_snapshot_read_only: " Snapshot is read-only; marked entries cannot be deleted ", + mark_no_destructive_keys: " No destructive keys are mapped; marked entries are safe ", + #[cfg(feature = "trash-move")] + mark_to_trash_or: " to trash or ", + mark_to_delete: " to delete without prompt", + mark_toggle: "mark-toggle", + mark_remove_all: "remove-all", + footer_sort_mode: "Sort mode", + footer_total_disk_usage: "Total disk usage", + sort_size_ascending: "size, small first", + sort_size_descending: "size, large first", + sort_mtime_ascending: "mtime, old first", + sort_mtime_descending: "mtime, new first", + sort_count_ascending: "items, few first", + sort_count_descending: "items, most first", + sort_name_ascending: "name, A-Z", + sort_name_descending: "name, Z-A", + sort_deep_newest: "deep newest", + sort_deep_oldest: "deep oldest", + snapshot_label: "", + snapshot_path_unavailable: "Snapshot path is unavailable: ", + snapshot_temporary_failed: "Could not create a temporary snapshot beside ", + snapshot_write_failed: "Could not write snapshot to ", + snapshot_install_failed: "Could not install snapshot at ", + failed_to_open: "Failed to open ", + top_level: "Top level reached", + entry_file_or_empty: "Entry is a file or an empty directory", + gitignore_snapshot_unavailable: "Gitignored entry detection is unavailable for snapshots", + scanning: "-> scanning <-", + snapshots_read_only: "Snapshots are read-only", + traversal_running: "Traversal already running", + deleting_items: "Deleting items...", + #[cfg(feature = "trash-move")] + trashing_items: "Trashing items...", + no_cleanup_candidates: "No cleanup candidates in view", + cleanup_candidates_already_marked: "Cleanup candidates are already marked", + cleanup_detection_disabled: "Cleanup candidate detection is disabled", + no_gitignored_entries: "No gitignored entries in view", + gitignored_entries_already_marked: "Gitignored entries are already marked", + gitignore_detection_disabled: "Gitignored entry detection is disabled", + no_match: "No match found", + notification_scan: "Scan", + notification_deletion: "Deletion", + #[cfg(feature = "trash-move")] + notification_trash: "Trash", + notification_finished: "finished", +}; + +const JA_UI: UiText = UiText { + header_help_before_key: "(", + header_help_after_key: "でヘルプ)", + entries_mark_move: "マーク↓", + entries_mark_toggle: "マーク切替", + entries_cleanup: "整理", + entries_gitignore: "Git無視", + entries_all: "全て", + glob_case_sensitive: "Git-Glob (大文字小文字を区別)", + glob_case_insensitive: "Git-Glob (大文字小文字を区別しない)", + glob_search: "検索", + glob_case: "大小", + glob_cancel: "取消", + glob_empty: "glob が空か空白のみです", + mark_snapshot_read_only: " スナップショットは読み取り専用のため削除できません ", + mark_no_destructive_keys: " 削除キーは未設定です。マーク済み項目は安全です ", + #[cfg(feature = "trash-move")] + mark_to_trash_or: " でゴミ箱へ、または ", + mark_to_delete: " で確認なしに削除", + mark_toggle: "マーク切替", + mark_remove_all: "全解除", + footer_sort_mode: "並べ替え", + footer_total_disk_usage: "ディスク使用量合計", + sort_size_ascending: "サイズ、小さい順", + sort_size_descending: "サイズ、大きい順", + sort_mtime_ascending: "mtime、古い順", + sort_mtime_descending: "mtime、新しい順", + sort_count_ascending: "項目数、少ない順", + sort_count_descending: "項目数、多い順", + sort_name_ascending: "名前、A-Z", + sort_name_descending: "名前、Z-A", + sort_deep_newest: "子孫の最新", + sort_deep_oldest: "子孫の最古", + snapshot_label: "<スナップショット>", + snapshot_path_unavailable: "スナップショットのパスを利用できません: ", + snapshot_temporary_failed: "次の場所に一時スナップショットを作成できませんでした: ", + snapshot_write_failed: "スナップショットを書き込めませんでした: ", + snapshot_install_failed: "スナップショットを配置できませんでした: ", + failed_to_open: "開けませんでした: ", + top_level: "最上位に到達しました", + entry_file_or_empty: "エントリはファイルまたは空のディレクトリです", + gitignore_snapshot_unavailable: "スナップショットでは Git 無視エントリを検出できません", + scanning: "-> スキャン中 <-", + snapshots_read_only: "スナップショットは読み取り専用です", + traversal_running: "スキャンはすでに実行中です", + deleting_items: "項目を削除中...", + #[cfg(feature = "trash-move")] + trashing_items: "項目をゴミ箱へ移動中...", + no_cleanup_candidates: "現在の表示にクリーンアップ候補はありません", + cleanup_candidates_already_marked: "クリーンアップ候補はすでにマーク済みです", + cleanup_detection_disabled: "クリーンアップ候補の検出は無効です", + no_gitignored_entries: "現在の表示に Git 無視エントリはありません", + gitignored_entries_already_marked: "Git 無視エントリはすでにマーク済みです", + gitignore_detection_disabled: "Git 無視エントリの検出は無効です", + no_match: "一致する項目がありません", + notification_scan: "スキャン", + notification_deletion: "削除", + #[cfg(feature = "trash-move")] + notification_trash: "ゴミ箱への移動", + notification_finished: "完了", +}; + +const KO_UI: UiText = UiText { + header_help_before_key: "(", + header_help_after_key: "를 눌러 도움말)", + entries_mark_move: "표시↓", + entries_mark_toggle: "표시전환", + entries_cleanup: "정리", + entries_gitignore: "Git무시", + entries_all: "전체", + glob_case_sensitive: "Git-Glob (대소문자 구분)", + glob_case_insensitive: "Git-Glob (대소문자 무시)", + glob_search: "검색", + glob_case: "대소문자", + glob_cancel: "취소", + glob_empty: "glob이 비어 있거나 공백뿐입니다", + mark_snapshot_read_only: " 스냅샷은 읽기 전용이므로 표시된 항목을 삭제할 수 없습니다 ", + mark_no_destructive_keys: " 삭제 키가 지정되지 않아 표시된 항목은 안전합니다 ", + #[cfg(feature = "trash-move")] + mark_to_trash_or: "로 휴지통 이동 또는 ", + mark_to_delete: "로 확인 없이 삭제", + mark_toggle: "표시전환", + mark_remove_all: "모두해제", + footer_sort_mode: "정렬", + footer_total_disk_usage: "총 디스크 사용량", + sort_size_ascending: "크기, 작은 순", + sort_size_descending: "크기, 큰 순", + sort_mtime_ascending: "mtime, 오래된 순", + sort_mtime_descending: "mtime, 최신 순", + sort_count_ascending: "항목 수, 적은 순", + sort_count_descending: "항목 수, 많은 순", + sort_name_ascending: "이름, A-Z", + sort_name_descending: "이름, Z-A", + sort_deep_newest: "하위 항목 중 최신", + sort_deep_oldest: "하위 항목 중 가장 오래됨", + snapshot_label: "<스냅샷>", + snapshot_path_unavailable: "스냅샷 경로를 사용할 수 없습니다: ", + snapshot_temporary_failed: "다음 위치에 임시 스냅샷을 만들 수 없습니다: ", + snapshot_write_failed: "스냅샷을 쓸 수 없습니다: ", + snapshot_install_failed: "스냅샷을 설치할 수 없습니다: ", + failed_to_open: "열지 못했습니다: ", + top_level: "최상위에 도달했습니다", + entry_file_or_empty: "항목이 파일이거나 빈 디렉터리입니다", + gitignore_snapshot_unavailable: "스냅샷에서는 Git 무시 항목 감지를 사용할 수 없습니다", + scanning: "-> 스캔 중 <-", + snapshots_read_only: "스냅샷은 읽기 전용입니다", + traversal_running: "스캔이 이미 실행 중입니다", + deleting_items: "항목 삭제 중...", + #[cfg(feature = "trash-move")] + trashing_items: "항목을 휴지통으로 이동 중...", + no_cleanup_candidates: "현재 보기에 정리 후보가 없습니다", + cleanup_candidates_already_marked: "정리 후보가 이미 표시되어 있습니다", + cleanup_detection_disabled: "정리 후보 감지가 비활성화되어 있습니다", + no_gitignored_entries: "현재 보기에 Git 무시 항목이 없습니다", + gitignored_entries_already_marked: "Git 무시 항목이 이미 표시되어 있습니다", + gitignore_detection_disabled: "Git 무시 항목 감지가 비활성화되어 있습니다", + no_match: "일치하는 항목이 없습니다", + notification_scan: "스캔", + notification_deletion: "삭제", + #[cfg(feature = "trash-move")] + notification_trash: "휴지통 이동", + notification_finished: "완료", +}; + +const ZH_UI: UiText = UiText { + header_help_before_key: "(按 ", + header_help_after_key: " 查看帮助)", + entries_mark_move: "标记↓", + entries_mark_toggle: "切换标记", + entries_cleanup: "清理", + entries_gitignore: "Git忽略", + entries_all: "全部", + glob_case_sensitive: "Git-Glob (区分大小写)", + glob_case_insensitive: "Git-Glob (不区分大小写)", + glob_search: "搜索", + glob_case: "大小写", + glob_cancel: "取消", + glob_empty: "glob 为空或仅包含空白", + mark_snapshot_read_only: " 快照为只读;无法删除已标记条目 ", + mark_no_destructive_keys: " 未映射破坏性按键;已标记条目是安全的 ", + #[cfg(feature = "trash-move")] + mark_to_trash_or: " 移至回收站,或 ", + mark_to_delete: " 无提示删除", + mark_toggle: "切换标记", + mark_remove_all: "全部移除", + footer_sort_mode: "排序", + footer_total_disk_usage: "磁盘总用量", + sort_size_ascending: "大小,小的优先", + sort_size_descending: "大小,大的优先", + sort_mtime_ascending: "mtime,旧的优先", + sort_mtime_descending: "mtime,新的优先", + sort_count_ascending: "条目数,少的优先", + sort_count_descending: "条目数,多的优先", + sort_name_ascending: "名称,A-Z", + sort_name_descending: "名称,Z-A", + sort_deep_newest: "子项中最新", + sort_deep_oldest: "子项中最旧", + snapshot_label: "<快照>", + snapshot_path_unavailable: "快照路径不可用: ", + snapshot_temporary_failed: "无法在目标旁创建临时快照: ", + snapshot_write_failed: "无法写入快照: ", + snapshot_install_failed: "无法安装快照: ", + failed_to_open: "无法打开: ", + top_level: "已到达顶层", + entry_file_or_empty: "条目是文件或空目录", + gitignore_snapshot_unavailable: "快照无法使用 Git 忽略条目检测", + scanning: "-> 正在扫描 <-", + snapshots_read_only: "快照为只读", + traversal_running: "扫描已在进行", + deleting_items: "正在删除条目...", + #[cfg(feature = "trash-move")] + trashing_items: "正在将条目移至回收站...", + no_cleanup_candidates: "当前视图中没有清理候选项", + cleanup_candidates_already_marked: "清理候选项已标记", + cleanup_detection_disabled: "清理候选项检测已禁用", + no_gitignored_entries: "当前视图中没有 Git 忽略条目", + gitignored_entries_already_marked: "Git 忽略条目已标记", + gitignore_detection_disabled: "Git 忽略条目检测已禁用", + no_match: "未找到匹配项", + notification_scan: "扫描", + notification_deletion: "删除", + #[cfg(feature = "trash-move")] + notification_trash: "移至回收站", + notification_finished: "完成", +}; + +const DE_UI: UiText = UiText { + header_help_before_key: "(", + header_help_after_key: " für Hilfe)", + entries_mark_move: "mark-move", + entries_mark_toggle: "mark-toggle", + entries_cleanup: "cleanup", + entries_gitignore: "gitignore", + entries_all: "all", + glob_case_sensitive: "Git-Glob (Groß/Klein beachten)", + glob_case_insensitive: "Git-Glob (Groß/Klein ignorieren)", + glob_search: "Suche", + glob_case: "case", + glob_cancel: "cancel", + glob_empty: "Glob ist leer oder enthält nur Leerzeichen", + mark_snapshot_read_only: " Snapshot ist schreibgeschützt; markierte Einträge sind nicht löschbar ", + mark_no_destructive_keys: " Keine Lösch-Tasten belegt; markierte Einträge sind sicher ", + #[cfg(feature = "trash-move")] + mark_to_trash_or: " in den Papierkorb oder ", + mark_to_delete: " ohne Rückfrage löschen", + mark_toggle: "mark-toggle", + mark_remove_all: "remove-all", + footer_sort_mode: "Sortierung", + footer_total_disk_usage: "Gesamte Speicherbelegung", + sort_size_ascending: "Größe, klein zuerst", + sort_size_descending: "Größe, groß zuerst", + sort_mtime_ascending: "mtime, alt zuerst", + sort_mtime_descending: "mtime, neu zuerst", + sort_count_ascending: "Einträge, wenige zuerst", + sort_count_descending: "Einträge, meiste zuerst", + sort_name_ascending: "Name, A-Z", + sort_name_descending: "Name, Z-A", + sort_deep_newest: "neuester Untereintrag", + sort_deep_oldest: "ältester Untereintrag", + snapshot_label: "", + snapshot_path_unavailable: "Snapshot-Pfad ist nicht verfügbar: ", + snapshot_temporary_failed: "Temporärer Snapshot konnte nicht neben diesem Pfad erstellt werden: ", + snapshot_write_failed: "Snapshot konnte nicht geschrieben werden: ", + snapshot_install_failed: "Snapshot konnte nicht installiert werden: ", + failed_to_open: "Öffnen fehlgeschlagen: ", + top_level: "Oberste Ebene erreicht", + entry_file_or_empty: "Eintrag ist eine Datei oder ein leeres Verzeichnis", + gitignore_snapshot_unavailable: "Git-ignorierte Einträge sind für Snapshots nicht ermittelbar", + scanning: "-> Scan läuft <-", + snapshots_read_only: "Snapshots sind schreibgeschützt", + traversal_running: "Scan läuft bereits", + deleting_items: "Einträge werden gelöscht...", + #[cfg(feature = "trash-move")] + trashing_items: "Einträge werden in den Papierkorb verschoben...", + no_cleanup_candidates: "Keine Bereinigungskandidaten in der Ansicht", + cleanup_candidates_already_marked: "Bereinigungskandidaten sind bereits markiert", + cleanup_detection_disabled: "Erkennung von Bereinigungskandidaten ist deaktiviert", + no_gitignored_entries: "Keine Git-ignorierten Einträge in der Ansicht", + gitignored_entries_already_marked: "Git-ignorierte Einträge sind bereits markiert", + gitignore_detection_disabled: "Erkennung Git-ignorierter Einträge ist deaktiviert", + no_match: "Keine Übereinstimmung gefunden", + notification_scan: "Scan", + notification_deletion: "Löschen", + #[cfg(feature = "trash-move")] + notification_trash: "Verschieben in den Papierkorb", + notification_finished: "abgeschlossen", +}; + #[cfg(test)] mod tests { use super::*; @@ -562,6 +1188,146 @@ mod tests { assert_eq!(detect([None, None, Some("POSIX")]), Language::English); } + #[test] + fn every_supported_language_has_interactive_ui_text() { + assert_eq!(Language::English.ui_text().footer_sort_mode, "Sort mode"); + assert_eq!(Language::Japanese.ui_text().footer_sort_mode, "並べ替え"); + assert_eq!(Language::Korean.ui_text().footer_sort_mode, "정렬"); + assert_eq!(Language::Chinese.ui_text().footer_sort_mode, "排序"); + assert_eq!(Language::German.ui_text().footer_sort_mode, "Sortierung"); + } + + #[test] + fn compact_action_labels_are_curated_per_language() { + let labels = |language: Language| { + let t = language.ui_text(); + ( + t.entries_mark_move, + t.entries_mark_toggle, + t.entries_cleanup, + t.entries_gitignore, + t.entries_all, + t.glob_search, + t.glob_case, + t.glob_cancel, + t.mark_toggle, + t.mark_remove_all, + ) + }; + + assert_eq!( + labels(Language::German), + ( + "mark-move", + "mark-toggle", + "cleanup", + "gitignore", + "all", + "Suche", + "case", + "cancel", + "mark-toggle", + "remove-all", + ) + ); + assert_eq!( + labels(Language::Japanese), + ( + "マーク↓", + "マーク切替", + "整理", + "Git無視", + "全て", + "検索", + "大小", + "取消", + "マーク切替", + "全解除", + ) + ); + assert_eq!( + labels(Language::Korean), + ( + "표시↓", + "표시전환", + "정리", + "Git무시", + "전체", + "검색", + "대소문자", + "취소", + "표시전환", + "모두해제", + ) + ); + assert_eq!( + labels(Language::Chinese), + ( + "标记↓", + "切换标记", + "清理", + "Git忽略", + "全部", + "搜索", + "大小写", + "取消", + "切换标记", + "全部移除", + ) + ); + } + + #[test] + fn dynamic_interface_messages_follow_each_language() { + let expected = [ + ( + Language::English, + "(4 visible, 43 total, 1.42 GB)", + "Processed 7 entries in 0.23s ", + "Scan finished: 42 entries, 2.00 MB in 1.2s, 2 errors", + ), + ( + Language::Japanese, + "(表示 4 件、合計 43 件、1.42 GB)", + "7 件を 0.23s で処理 ", + "スキャン完了: 42 件、2.00 MB、所要時間 1.2s、エラー 2 件", + ), + ( + Language::Korean, + "(표시 4개, 전체 43개, 1.42 GB)", + "7개 항목을 0.23s 동안 처리 ", + "스캔 완료: 42개 항목, 2.00 MB, 소요 시간 1.2s, 오류 2건", + ), + ( + Language::Chinese, + "(显示 4 项,共 43 项,1.42 GB)", + "已处理 7 个条目,用时 0.23s ", + "扫描完成:42 个条目,2.00 MB,用时 1.2s,2 个错误", + ), + ( + Language::German, + "(4 sichtbar, 43 gesamt, 1.42 GB)", + "7 Einträge in 0.23s verarbeitet ", + "Scan abgeschlossen: 42 Einträge, 2.00 MB in 1.2s, 2 Fehler", + ), + ]; + + for (language, statistics, progress, notification) in expected { + assert_eq!(language.entries_statistics(4, "43", "1.42 GB"), statistics); + assert_eq!(language.footer_progress(7, "0.23s"), progress); + assert_eq!( + language.notification_summary( + language.ui_text().notification_scan, + 42, + "2.00 MB", + "1.2s", + 2, + ), + notification + ); + } + } + #[test] fn precedence_lc_all_over_lc_messages_over_lang() { // LC_ALL wins over everything. diff --git a/src/interactive/widgets/main.rs b/src/interactive/widgets/main.rs index 741ae2e1..d2efa1bc 100644 --- a/src/interactive/widgets/main.rs +++ b/src/interactive/widgets/main.rs @@ -54,15 +54,16 @@ impl MainWindow { state, config, } = props.borrow(); + let language = state.language; let (entries_style, help_style, mark_style, glob_style) = pane_border_style(state.focussed); let (header_area, content_area, footer_area) = main_window_layout(area); - let safety_notice = mark_safety_notice(state.read_only, &config.keys); + let safety_notice = mark_safety_notice(state.read_only, &config.keys, language); let header_bg_color = header_background_color(self.has_marks() && safety_notice.is_none(), state.focussed); - Header::render(header_bg_color, header_area, buffer); + Header::render(language, header_bg_color, header_area, buffer); let (entries_area, help_pane, mark_pane) = { let (left_pane, right_pane) = content_layout(content_area); @@ -95,6 +96,7 @@ impl MainWindow { root_total_size: *total_bytes, keys: &config.keys, safety_notice, + language, }; pane.render(props, mark_area, buffer); } @@ -104,6 +106,7 @@ impl MainWindow { border_style: help_style, has_focus: matches!(state.focussed, Help), keys: &config.keys, + language, }; pane.render(props, help_area, buffer); } @@ -122,6 +125,7 @@ impl MainWindow { sort_mode: state.sorting, show_columns: &state.show_columns, keys: &config.keys, + language, }; self.entries.render(props, entries_area, buffer); @@ -130,6 +134,7 @@ impl MainWindow { border_style: glob_style, has_focus: matches!(state.focussed, Glob), keys: &config.keys, + language, }; pane.render(props, glob_area, buffer, cursor); } @@ -147,6 +152,7 @@ impl MainWindow { sort_mode: state.sorting, pending_exit: state.pending_exit, keys: &config.keys, + language, }, footer_area, buffer, @@ -177,13 +183,18 @@ fn content_layout(content_area: Rect) -> (Rect, Rect) { (regions[0], regions[1]) } -fn mark_safety_notice(read_only: bool, keys: &dua::KeysConfig) -> Option<&'static str> { +fn mark_safety_notice( + read_only: bool, + keys: &dua::KeysConfig, + language: crate::interactive::widgets::Language, +) -> Option<&'static str> { + let t = language.ui_text(); if read_only { - Some(" Snapshot is read-only; marked entries cannot be deleted ") + Some(t.mark_snapshot_read_only) } else if keys.delete_marked.is_empty() && (!cfg!(feature = "trash-move") || keys.trash_marked.is_empty()) { - Some(" No destructive keys are mapped; marked entries are safe ") + Some(t.mark_no_destructive_keys) } else { None } @@ -224,18 +235,19 @@ fn pane_border_style(focused_pane: FocussedPane) -> (Style, Style, Style, Style) #[cfg(test)] mod tests { use super::*; + use crate::interactive::widgets::Language; #[test] fn marks_are_only_dangerous_when_a_destructive_action_is_available() { let config = dua::Config::default(); - assert!(mark_safety_notice(false, &config.keys).is_none()); + assert!(mark_safety_notice(false, &config.keys, Language::English).is_none()); assert_eq!(header_background_color(true, Mark), Color::LightRed); - assert!(mark_safety_notice(true, &config.keys).is_some()); + assert!(mark_safety_notice(true, &config.keys, Language::English).is_some()); assert_eq!(header_background_color(false, Mark), Color::White); let config: dua::Config = toml::from_str("[keys]\ndelete_marked = []\ntrash_marked = []").expect("valid config"); - assert!(mark_safety_notice(false, &config.keys).is_some()); + assert!(mark_safety_notice(false, &config.keys, Language::English).is_some()); } } diff --git a/src/interactive/widgets/mark.rs b/src/interactive/widgets/mark.rs index 14751433..96b6f5f1 100644 --- a/src/interactive/widgets/mark.rs +++ b/src/interactive/widgets/mark.rs @@ -4,8 +4,10 @@ use crate::interactive::widgets::tui_ext::{ util::{block_width, rect, rect::line_bound}, }; use crate::interactive::{ - CursorDirection, app::tree_view::TreeView, fit_string_graphemes_with_ellipsis, - widgets::entry_color, + CursorDirection, + app::tree_view::TreeView, + fit_string_graphemes_with_ellipsis, + widgets::{Language, entry_color}, }; use crossterm::event::{KeyEvent, KeyEventKind}; use dua::{ByteFormat, KeysConfig, traverse::TreeIndex}; @@ -62,6 +64,7 @@ pub struct MarkPaneProps<'a> { pub root_total_size: u128, pub keys: &'a KeysConfig, pub safety_notice: Option<&'static str>, + pub language: Language, } impl MarkPane { @@ -266,6 +269,7 @@ impl MarkPane { root_total_size, keys, safety_notice, + language, } = props.borrow(); let marked: &_ = &self.marked; @@ -274,11 +278,11 @@ impl MarkPane { } else { self.total_size as f64 / *root_total_size as f64 * 100.0 }; - let title = format!( - "Marked {} items ({}, {percentage:.2}% of {}) ", - COUNT.format(self.item_count as f64), - format.display(self.total_size), - format.display(*root_total_size) + let title = language.marked_title( + &COUNT.format(self.item_count as f64), + &format.display(self.total_size).to_string(), + percentage, + &format.display(*root_total_size).to_string(), ); let selected = self.selected; let has_focus = self.has_focus; @@ -302,7 +306,7 @@ impl MarkPane { " {} {}", v.path.display(), if v.num_errors_during_deletion != 0 { - format!("{} IO deletion errors", v.num_errors_during_deletion) + language.deletion_errors(v.num_errors_during_deletion) } else { String::new() } @@ -401,6 +405,7 @@ impl MarkPane { sub_modifier: Modifier::empty(), ..Style::default() }; + let t = language.ui_text(); if let Some(notice) = safety_notice { Paragraph::new(*notice).render(help_line_area, buf); } else { @@ -415,7 +420,7 @@ impl MarkPane { }, ), #[cfg(feature = "trash-move")] - Span::styled(" to trash or ", default_style), + Span::styled(t.mark_to_trash_or, default_style), Span::styled( format!(" {} ", keys.delete_marked), Style { @@ -425,7 +430,7 @@ impl MarkPane { ..default_style }, ), - Span::styled(" to delete without prompt", default_style), + Span::styled(t.mark_to_delete, default_style), ]))) .style(default_style) .render(help_line_area, buf); @@ -463,6 +468,7 @@ impl MarkPane { ); if has_focus { + let t = language.ui_text(); let help_text = format!( " ⇊ = {}|↓ = {}|⇈ = {}|↑ = {} ", keys.page_down.primary(), @@ -485,8 +491,8 @@ impl MarkPane { } let bound = line_bound(bound, bound.height.saturating_sub(1) as usize); let help_text = format!( - " mark-toggle = {} | remove-all = {}", - keys.remove_mark, keys.remove_all_marks + " {} = {} | {} = {}", + t.mark_toggle, keys.remove_mark, t.mark_remove_all, keys.remove_all_marks ); let help_text_block_width = block_width(&help_text); if help_text_block_width <= bound.width { @@ -529,6 +535,7 @@ pub fn calculate_size_and_count(marked: &EntryMarkMap) -> (u128, u64) { mod mark_pane_tests { use super::*; use crossterm::event::{KeyCode, KeyModifiers}; + use tui::buffer::Cell; #[test] fn title_shows_percentage_of_root_size() { @@ -546,6 +553,7 @@ mod mark_pane_tests { keys: &KeysConfig::default(), root_total_size: 1_000_000_000, safety_notice: None, + language: Language::English, }, area, &mut buffer, @@ -589,6 +597,7 @@ mod mark_pane_tests { keys: &config.keys, root_total_size: 0, safety_notice: None, + language: Language::English, }, area, &mut buffer, @@ -658,6 +667,7 @@ mod mark_pane_tests { keys: &KeysConfig::default(), root_total_size: 0, safety_notice: Some(" Snapshot is read-only; marked entries cannot be deleted "), + language: Language::English, }, area, &mut buffer, @@ -683,6 +693,37 @@ mod mark_pane_tests { ); } + #[test] + fn title_prompt_and_actions_follow_the_selected_language() { + let area = Rect::new(0, 0, 120, 4); + let mut buffer = Buffer::empty(area); + + MarkPane { + has_focus: true, + ..Default::default() + } + .render( + MarkPaneProps { + border_style: Style::default(), + format: ByteFormat::Metric, + keys: &KeysConfig::default(), + root_total_size: 0, + safety_notice: None, + language: Language::Korean, + }, + area, + &mut buffer, + ); + + let rendered: String = buffer.content.iter().map(Cell::symbol).collect(); + let rendered: String = rendered.split_whitespace().collect(); + assert!(rendered.contains("표시된항목0개")); + assert!(rendered.contains("확인없이삭제")); + assert!(rendered.contains("표시전환")); + assert!(rendered.contains("모두해제")); + assert!(!rendered.contains("Marked")); + } + #[test] fn process_events_uses_configured_keybindings() { let config: dua::Config = toml::from_str(