Internationalize UI text w/ german as first second lang

Adds rust-i18n and refactors all of the text copy in the app to use the
translation function so that the UI language can be dynamically updated
in the settings.
This commit is contained in:
2026-03-17 04:29:25 +00:00
parent 895e04d6ce
commit 6d5de33f55
24 changed files with 2924 additions and 820 deletions

View File

@@ -4,6 +4,7 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
use crate::i18n::t;
use crate::session::result::DrillResult;
use crate::ui::layout::pack_hint_lines;
use crate::ui::theme::Theme;
@@ -32,8 +33,9 @@ impl Widget for Dashboard<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let title_text = t!("dashboard.title");
let block = Block::bordered()
.title(" Drill Complete ")
.title(title_text.to_string())
.border_style(Style::default().fg(colors.accent()))
.style(Style::default().bg(colors.bg()));
let inner = block.inner(area);
@@ -42,12 +44,17 @@ impl Widget for Dashboard<'_> {
let footer_line_count = if self.input_lock_remaining_ms.is_some() {
1u16
} else {
let hint_continue = t!("dashboard.hint_continue");
let hint_retry = t!("dashboard.hint_retry");
let hint_menu = t!("dashboard.hint_menu");
let hint_stats = t!("dashboard.hint_stats");
let hint_delete = t!("dashboard.hint_delete");
let hints = [
"[c/Enter/Space] Continue",
"[r] Retry",
"[q] Menu",
"[s] Stats",
"[x] Delete",
hint_continue.as_ref(),
hint_retry.as_ref(),
hint_menu.as_ref(),
hint_stats.as_ref(),
hint_delete.as_ref(),
];
pack_hint_lines(&hints, inner.width as usize).len().max(1) as u16
};
@@ -65,25 +72,32 @@ impl Widget for Dashboard<'_> {
])
.split(inner);
let results_label = t!("dashboard.results");
let mut title_spans = vec![Span::styled(
"Results",
results_label.to_string(),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)];
if !self.result.ranked {
let unranked_note = format!(
"{}\u{2014}{}",
t!("dashboard.unranked_note_prefix"),
t!("dashboard.unranked_note_suffix")
);
title_spans.push(Span::styled(
" (Unranked \u{2014} does not count toward skill tree)",
unranked_note,
Style::default().fg(colors.text_pending()),
));
}
let title = Paragraph::new(Line::from(title_spans)).alignment(Alignment::Center);
title.render(layout[0], buf);
let speed_label = t!("dashboard.speed");
let wpm_text = format!("{:.0} WPM", self.result.wpm);
let cpm_text = format!(" ({:.0} CPM)", self.result.cpm);
let wpm_line = Line::from(vec![
Span::styled(" Speed: ", Style::default().fg(colors.fg())),
Span::styled(speed_label.to_string(), Style::default().fg(colors.fg())),
Span::styled(
&*wpm_text,
Style::default()
@@ -101,31 +115,31 @@ impl Widget for Dashboard<'_> {
} else {
colors.error()
};
let accuracy_label = t!("dashboard.accuracy_label");
let acc_text = format!("{:.1}%", self.result.accuracy);
let acc_detail = format!(
" ({}/{} correct)",
self.result.correct, self.result.total_chars
);
let acc_detail = t!("dashboard.correct_detail", correct = self.result.correct, total = self.result.total_chars);
let acc_line = Line::from(vec![
Span::styled(" Accuracy: ", Style::default().fg(colors.fg())),
Span::styled(accuracy_label.to_string(), Style::default().fg(colors.fg())),
Span::styled(
&*acc_text,
Style::default().fg(acc_color).add_modifier(Modifier::BOLD),
),
Span::styled(&*acc_detail, Style::default().fg(colors.text_pending())),
Span::styled(acc_detail.to_string(), Style::default().fg(colors.text_pending())),
]);
Paragraph::new(acc_line).render(layout[2], buf);
let time_label = t!("dashboard.time_label");
let time_text = format!("{:.1}s", self.result.elapsed_secs);
let time_line = Line::from(vec![
Span::styled(" Time: ", Style::default().fg(colors.fg())),
Span::styled(time_label.to_string(), Style::default().fg(colors.fg())),
Span::styled(&*time_text, Style::default().fg(colors.fg())),
]);
Paragraph::new(time_line).render(layout[3], buf);
let errors_label = t!("dashboard.errors_label");
let error_text = format!("{}", self.result.incorrect);
let chars_line = Line::from(vec![
Span::styled(" Errors: ", Style::default().fg(colors.fg())),
Span::styled(errors_label.to_string(), Style::default().fg(colors.fg())),
Span::styled(
&*error_text,
Style::default().fg(if self.result.incorrect == 0 {
@@ -138,25 +152,32 @@ impl Widget for Dashboard<'_> {
Paragraph::new(chars_line).render(layout[4], buf);
let help = if let Some(ms) = self.input_lock_remaining_ms {
let input_blocked_label = t!("dashboard.input_blocked");
let input_blocked_ms = t!("dashboard.input_blocked_ms", ms = ms);
Paragraph::new(Line::from(vec![
Span::styled(
" Input temporarily blocked ",
input_blocked_label.to_string(),
Style::default().fg(colors.warning()),
),
Span::styled(
format!("({ms}ms remaining)"),
input_blocked_ms.to_string(),
Style::default()
.fg(colors.warning())
.add_modifier(Modifier::BOLD),
),
]))
} else {
let hint_continue = t!("dashboard.hint_continue");
let hint_retry = t!("dashboard.hint_retry");
let hint_menu = t!("dashboard.hint_menu");
let hint_stats = t!("dashboard.hint_stats");
let hint_delete = t!("dashboard.hint_delete");
let hints = [
"[c/Enter/Space] Continue",
"[r] Retry",
"[q] Menu",
"[s] Stats",
"[x] Delete",
hint_continue.as_ref(),
hint_retry.as_ref(),
hint_menu.as_ref(),
hint_stats.as_ref(),
hint_delete.as_ref(),
];
let lines: Vec<Line> = pack_hint_lines(&hints, inner.width as usize)
.into_iter()