use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use crate::localization::MessageId;
use crate::palette;
use crate::tui::app::App;
use unicode_width::UnicodeWidthStr;
pub const LANGUAGE_OPTIONS: &[(char, &str, &str, &str)] = &[
('1', "auto", "Auto-detect", "(LC_ALL / LANG)"),
('2', "en", "English", ""),
('3', "ja", "日本語", "(Japanese)"),
('4', "zh-Hans", "简体中文", "(Simplified Chinese)"),
('5', "zh-Hant", "繁體中文", "(Traditional Chinese)"),
('6', "pt-BR", "Português (Brasil)", "(Brazilian Portuguese)"),
(
'7',
"es-419",
"Español (Latinoamérica)",
"(Latin American Spanish)",
),
('8', "vi", "Tiếng Việt", "(Vietnamese)"),
('9', "ko", "한국어", "(Korean)"),
('a', "ca", "Català", "(Catalan)"),
('b', "de", "Deutsch", "(German)"),
('c', "fr", "Français", "(French)"),
('d', "id", "Bahasa Indonesia", "(Indonesian)"),
('e', "hi", "हिन्दी", "(Hindi)"),
('f', "ru", "Русский", "(Russian)"),
('g', "uk", "Українська", "(Ukrainian)"),
];
const TWO_COLUMN_MIN_WIDTH: usize = 56;
pub fn lines(app: &App, width: usize, height: usize) -> Vec<Line<'static>> {
let current_owned = app.current_locale_tag();
let current = current_owned.as_str();
let title = Line::from(Span::styled(
app.tr(MessageId::OnboardLanguageTitle).to_string(),
Style::default()
.fg(palette::WHALE_INFO)
.add_modifier(Modifier::BOLD),
));
if height < 12 {
let option_rows = height.saturating_sub(1).max(1);
return compact_grid(title, width, option_rows, current);
}
let mut out: Vec<Line<'static>> = vec![title, Line::from("")];
for segment in super::wrap_words(&app.tr(MessageId::OnboardLanguageBlurb), width) {
out.push(Line::from(Span::styled(
segment,
Style::default().fg(palette::TEXT_PRIMARY),
)));
}
out.push(Line::from(""));
let two_column = width >= TWO_COLUMN_MIN_WIDTH;
if two_column {
let column_width = (width - 3) / 2;
let split = LANGUAGE_OPTIONS.len().div_ceil(2);
let (left_column, right_column) = LANGUAGE_OPTIONS.split_at(split);
for (idx, left) in left_column.iter().enumerate() {
let mut spans = option_spans(left, current);
if let Some(right) = right_column.get(idx) {
let used: usize = spans
.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.sum();
let gap = (column_width + 1).saturating_sub(used).max(2);
spans.push(Span::raw(" ".repeat(gap)));
spans.extend(option_spans(right, current));
}
out.push(Line::from(spans));
}
} else {
for option in LANGUAGE_OPTIONS {
out.push(Line::from(option_spans_with_english(option, current)));
}
}
out
}
fn compact_grid(
title: Line<'static>,
width: usize,
rows: usize,
current: &str,
) -> Vec<Line<'static>> {
let columns = LANGUAGE_OPTIONS.len().div_ceil(rows);
let gap = usize::from(columns > 1);
let column_width = width
.saturating_sub(gap.saturating_mul(columns.saturating_sub(1)))
.checked_div(columns)
.unwrap_or(1)
.max(1);
let mut out = vec![title];
for row in 0..rows {
let mut spans = Vec::new();
for column in 0..columns {
let index = row * columns + column;
let Some(option) = LANGUAGE_OPTIONS.get(index) else {
break;
};
if column > 0 {
spans.push(Span::raw(" ".repeat(gap)));
}
spans.extend(compact_option_spans(option, current, column_width));
}
out.push(Line::from(spans));
}
out
}
fn compact_option_spans(
option: &(char, &str, &str, &str),
current: &str,
width: usize,
) -> Vec<Span<'static>> {
let (hotkey, tag, native, _) = *option;
let prefix = format!("[{hotkey}]");
let prefix_width = UnicodeWidthStr::width(prefix.as_str());
let label_width = width.saturating_sub(prefix_width);
let label = crate::tui::ui_text::semantic_truncate(native, label_width);
let style = if current == tag {
Style::default()
.fg(palette::WHALE_ACTION)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
let used = prefix_width + UnicodeWidthStr::width(label.as_str());
vec![
Span::styled(prefix, style),
Span::styled(label, style),
Span::raw(" ".repeat(width.saturating_sub(used))),
]
}
fn option_spans(option: &(char, &str, &str, &str), current: &str) -> Vec<Span<'static>> {
option_spans_inner(option, current, false)
}
fn option_spans_with_english(
option: &(char, &str, &str, &str),
current: &str,
) -> Vec<Span<'static>> {
option_spans_inner(option, current, true)
}
fn option_spans_inner(
option: &(char, &str, &str, &str),
current: &str,
with_english: bool,
) -> Vec<Span<'static>> {
let (hotkey, tag, native, english) = *option;
let is_current = current == tag;
let bullet = if is_current {
crate::tui::glyphs::CURRENT
} else {
crate::tui::glyphs::AVAILABLE
};
let bullet_color = if is_current {
palette::WHALE_ACTION
} else {
palette::TEXT_MUTED
};
let mut spans: Vec<Span<'static>> = vec![
Span::styled(format!("{bullet} "), Style::default().fg(bullet_color)),
Span::styled(
format!("[{hotkey}] "),
Style::default()
.fg(palette::TEXT_PRIMARY)
.add_modifier(Modifier::BOLD),
),
Span::styled(
native.to_string(),
Style::default().fg(palette::TEXT_PRIMARY),
),
];
if with_english && !english.is_empty() {
spans.push(Span::styled(
format!(" {english}"),
Style::default().fg(palette::TEXT_MUTED),
));
}
spans
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::localization::Locale;
use crate::tui::app::TuiOptions;
use std::path::PathBuf;
fn app() -> App {
let options = TuiOptions {
..crate::test_support::test_tui_options(PathBuf::from("."))
};
let mut app = App::new(options, &Config::default());
app.ui_locale = Locale::En;
app
}
fn row_text(line: &Line<'static>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
#[test]
fn picker_offers_every_shipped_locale() {
let offered: Vec<&str> = LANGUAGE_OPTIONS.iter().map(|(_, tag, _, _)| *tag).collect();
assert!(
offered.contains(&"auto"),
"picker must keep the auto-detect entry"
);
for locale in Locale::shipped() {
let tag = locale.tag();
assert!(
offered.contains(&tag),
"shipped locale {tag} is not offered in the language picker"
);
}
}
#[test]
fn picker_hotkeys_are_contiguous_digits_then_letters() {
for (idx, (hotkey, tag, _, _)) in LANGUAGE_OPTIONS.iter().enumerate() {
let expected = if idx < 9 {
char::from_digit((idx + 1) as u32, 10).expect("digit")
} else {
char::from_u32('a' as u32 + (idx - 9) as u32).expect("letter")
};
assert_eq!(
*hotkey, expected,
"option {tag} should use hotkey {expected}, not {hotkey}"
);
}
}
#[test]
fn every_option_is_visible_at_80_columns() {
let rows = lines(&app(), 72, 17);
let text = rows.iter().map(row_text).collect::<Vec<_>>();
for (_, tag, native, _) in LANGUAGE_OPTIONS {
let shown = text.iter().any(|row| row.contains(native));
assert!(shown, "option {tag} ({native}) missing from the picker");
}
assert!(
rows.len() <= 15,
"picker is {} rows; it must fit 80x24",
rows.len()
);
}
#[test]
fn compact_grid_keeps_every_language_hotkey_reachable() {
let rows = lines(&app(), 36, 5);
let text = rows.iter().map(row_text).collect::<Vec<_>>().join("\n");
assert_eq!(rows.len(), 5, "one title plus four option rows");
for (hotkey, tag, _, _) in LANGUAGE_OPTIONS {
assert!(text.contains(&format!("[{hotkey}]")), "missing {tag}");
}
}
}