diff --git a/README.md b/README.md index 0a4b23ce..fb0e3b73 100644 --- a/README.md +++ b/README.md @@ -235,12 +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; currently -Japanese (`ja`) is 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=ja_JP.UTF-8 dua i # then press '?' for the Japanese 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 fc31b12f..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| { @@ -292,17 +285,18 @@ 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, - ..Default::default() - } - .render( + HelpPane::default().render( HelpPaneProps { border_style: Style::default(), has_focus: false, keys, + language, }, area, &mut buf, @@ -340,6 +334,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( @@ -389,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 902be33a..f8f0b1af 100644 --- a/src/interactive/widgets/i18n.rs +++ b/src/interactive/widgets/i18n.rs @@ -1,18 +1,20 @@ -//! 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] English, Japanese, + Korean, + Chinese, + German, } impl Language { @@ -30,6 +32,20 @@ impl Language { match self { Language::English => &EN, Language::Japanese => &JA, + Language::Korean => &KO, + Language::Chinese => &ZH, + 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, } } } @@ -38,10 +54,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 +64,19 @@ 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_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, _ => Language::English, } } -fn is_japanese_utf8(locale: &str) -> bool { +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)), @@ -65,7 +85,9 @@ 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, language_region)) } fn is_utf8_codeset(codeset: &str) -> bool { @@ -269,6 +291,814 @@ 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: "애플리케이션을 종료합니다. 확인하지 않습니다!", +}; + +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: "直接关闭应用程序,不作确认!", +}; + +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!", +}; + +/// 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::*; @@ -300,7 +1130,38 @@ 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 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); + 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!( + detect([None, None, Some("de_DE.ISO-8859-1")]), + Language::English + ); assert_eq!( detect([None, None, Some("ja_JP.SJIS")]), Language::English, @@ -310,15 +1171,163 @@ mod tests { detect([None, None, Some("ja_JP.EUC-JP")]), Language::English ); + assert_eq!( + detect([None, None, Some("ko_KR.EUC-KR")]), + Language::English + ); + assert_eq!( + detect([None, None, Some("zh_CN.GB18030")]), + 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); } + #[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(