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

@@ -7,6 +7,7 @@ use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Widget};
use crate::i18n::t;
use crate::session::result::DrillResult;
use crate::ui::theme::Theme;
@@ -27,7 +28,7 @@ impl Widget for ActivityHeatmap<'_> {
let block = Block::bordered()
.title(Line::from(Span::styled(
" Daily Activity (Sessions per Day) ",
t!("heatmap.title"),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
@@ -87,27 +88,27 @@ impl Widget for ActivityHeatmap<'_> {
// Month label on first row
let month = current_date.month();
if month != last_month {
let month_name = match month {
1 => "Jan",
2 => "Feb",
3 => "Mar",
4 => "Apr",
5 => "May",
6 => "Jun",
7 => "Jul",
8 => "Aug",
9 => "Sep",
10 => "Oct",
11 => "Nov",
12 => "Dec",
_ => "",
let month_name: std::borrow::Cow<'_, str> = match month {
1 => t!("heatmap.jan"),
2 => t!("heatmap.feb"),
3 => t!("heatmap.mar"),
4 => t!("heatmap.apr"),
5 => t!("heatmap.may"),
6 => t!("heatmap.jun"),
7 => t!("heatmap.jul"),
8 => t!("heatmap.aug"),
9 => t!("heatmap.sep"),
10 => t!("heatmap.oct"),
11 => t!("heatmap.nov"),
12 => t!("heatmap.dec"),
_ => std::borrow::Cow::Borrowed(""),
};
// Only show if we have space (3 chars)
if x + 3 <= inner.x + inner.width {
buf.set_string(
x,
inner.y,
month_name,
month_name.as_ref(),
Style::default().fg(colors.text_pending()),
);
}

View File

@@ -5,6 +5,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
use crate::engine::skill_tree::{BranchId, DrillScope, SkillTree, get_branch_definition};
use crate::i18n::t;
use crate::ui::theme::Theme;
pub struct BranchProgressList<'a> {
@@ -92,7 +93,7 @@ impl Widget for BranchProgressList<'_> {
let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12);
lines.push(Line::from(vec![
Span::styled(
format!(" \u{25b6} {:<14}", def.name),
format!(" \u{25b6} {:<14}", def.display_name()),
Style::default().fg(colors.accent()),
),
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
@@ -123,9 +124,12 @@ impl Widget for BranchProgressList<'_> {
0
};
let right_pad = if area.width >= 75 { 2 } else { 0 };
let label = format!("{}Overall Key Progress ", " ".repeat(left_pad));
let overall_label = t!("progress.overall_key_progress");
let label = format!("{}{} ", " ".repeat(left_pad), overall_label);
let unlocked_mastered = t!("progress.unlocked_mastered", unlocked = unlocked, total = total, mastered = mastered);
let suffix = format!(
" {unlocked}/{total} unlocked ({mastered} mastered){}",
" {}{}",
unlocked_mastered,
" ".repeat(right_pad)
);
let reserved = label.len() + suffix.len();
@@ -185,7 +189,8 @@ fn render_branch_cell<'a>(
let fixed = prefix.len() + name_width + 1 + count.len();
let bar_width = cell_width.saturating_sub(fixed).max(6);
let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, bar_width);
let name = truncate_and_pad(def.name, name_width);
let display = def.display_name();
let name = truncate_and_pad(&display, name_width);
let mut spans: Vec<Span> = vec![
Span::styled(prefix.to_string(), Style::default().fg(label_color)),

View File

@@ -4,6 +4,7 @@ use ratatui::style::Style;
use ratatui::symbols;
use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Widget};
use crate::i18n::t;
use crate::ui::theme::Theme;
#[allow(dead_code)]
@@ -24,8 +25,9 @@ impl Widget for WpmChart<'_> {
let colors = &self.theme.colors;
if self.data.is_empty() {
let wpm_title = t!("chart.wpm_over_time");
let block = Block::bordered()
.title(" WPM Over Time ")
.title(wpm_title.to_string())
.border_style(Style::default().fg(colors.border()));
block.render(area, buf);
return;
@@ -45,21 +47,24 @@ impl Widget for WpmChart<'_> {
.style(Style::default().fg(colors.accent()))
.data(self.data);
let wpm_title = t!("chart.wpm_over_time");
let drill_number_label = t!("chart.drill_number");
let wpm_label = t!("common.wpm");
let chart = Chart::new(vec![dataset])
.block(
Block::bordered()
.title(" WPM Over Time ")
.title(wpm_title.to_string())
.border_style(Style::default().fg(colors.border())),
)
.x_axis(
Axis::default()
.title("Drill #")
.title(drill_number_label.to_string())
.style(Style::default().fg(colors.text_pending()))
.bounds([0.0, max_x]),
)
.y_axis(
Axis::default()
.title("WPM")
.title(wpm_label.to_string())
.style(Style::default().fg(colors.text_pending()))
.bounds([0.0, max_y * 1.1]),
);

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()

View File

@@ -4,16 +4,20 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
use crate::i18n::t;
use crate::ui::theme::Theme;
pub struct MenuItem {
pub key: String,
pub label: String,
pub description: String,
}
const MENU_ITEMS: &[(&str, &str, &str)] = &[
("1", "menu.adaptive_drill", "menu.adaptive_drill_desc"),
("2", "menu.code_drill", "menu.code_drill_desc"),
("3", "menu.passage_drill", "menu.passage_drill_desc"),
("t", "menu.skill_tree", "menu.skill_tree_desc"),
("b", "menu.keyboard", "menu.keyboard_desc"),
("s", "menu.statistics", "menu.statistics_desc"),
("c", "menu.settings", "menu.settings_desc"),
];
pub struct Menu<'a> {
pub items: Vec<MenuItem>,
pub selected: usize,
pub theme: &'a Theme,
}
@@ -21,57 +25,24 @@ pub struct Menu<'a> {
impl<'a> Menu<'a> {
pub fn new(theme: &'a Theme) -> Self {
Self {
items: vec![
MenuItem {
key: "1".to_string(),
label: "Adaptive Drill".to_string(),
description: "Phonetic words with adaptive letter unlocking".to_string(),
},
MenuItem {
key: "2".to_string(),
label: "Code Drill".to_string(),
description: "Practice typing code syntax".to_string(),
},
MenuItem {
key: "3".to_string(),
label: "Passage Drill".to_string(),
description: "Type passages from books".to_string(),
},
MenuItem {
key: "t".to_string(),
label: "Skill Tree".to_string(),
description: "View progression branches and launch drills".to_string(),
},
MenuItem {
key: "b".to_string(),
label: "Keyboard".to_string(),
description: "Explore keyboard layout and key statistics".to_string(),
},
MenuItem {
key: "s".to_string(),
label: "Statistics".to_string(),
description: "View your typing statistics".to_string(),
},
MenuItem {
key: "c".to_string(),
label: "Settings".to_string(),
description: "Configure keydr".to_string(),
},
],
selected: 0,
theme,
}
}
pub fn item_count() -> usize {
MENU_ITEMS.len()
}
pub fn next(&mut self) {
self.selected = (self.selected + 1) % self.items.len();
self.selected = (self.selected + 1) % MENU_ITEMS.len();
}
pub fn prev(&mut self) {
if self.selected > 0 {
self.selected -= 1;
} else {
self.selected = self.items.len() - 1;
self.selected = MENU_ITEMS.len() - 1;
}
}
}
@@ -95,6 +66,7 @@ impl Widget for &Menu<'_> {
])
.split(inner);
let subtitle = t!("menu.subtitle");
let title_lines = vec![
Line::from(""),
Line::from(Span::styled(
@@ -104,7 +76,7 @@ impl Widget for &Menu<'_> {
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"Terminal Typing Tutor",
subtitle.as_ref(),
Style::default().fg(colors.fg()),
)),
Line::from(""),
@@ -116,33 +88,31 @@ impl Widget for &Menu<'_> {
let menu_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
self.items
MENU_ITEMS
.iter()
.map(|_| Constraint::Length(3))
.collect::<Vec<_>>(),
)
.split(layout[2]);
let key_width = self
.items
let key_width = MENU_ITEMS
.iter()
.map(|item| item.key.len())
.map(|(key, _, _)| key.len())
.max()
.unwrap_or(1);
for (i, item) in self.items.iter().enumerate() {
for (i, &(key, label_key, desc_key)) in MENU_ITEMS.iter().enumerate() {
let is_selected = i == self.selected;
let indicator = if is_selected { ">" } else { " " };
let label = t!(label_key);
let description = t!(desc_key);
let label_text = format!(
" {indicator} [{key:<key_width$}] {label}",
key = item.key,
key_width = key_width,
label = item.label
);
let desc_text = format!(
" {:indent$}{}",
" {:indent$}{description}",
"",
item.description,
indent = key_width + 4
);

View File

@@ -4,6 +4,7 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
use crate::i18n::t;
use crate::engine::key_stats::KeyStatsStore;
use crate::engine::skill_tree::{
BranchId, BranchStatus, DrillScope, SkillTree as SkillTreeEngine, get_branch_definition,
@@ -38,10 +39,7 @@ impl<'a> SkillTreeWidget<'a> {
}
fn locked_branch_notice(skill_tree: &SkillTreeEngine) -> String {
format!(
"Complete {} primary letters to unlock branches",
skill_tree.primary_letters().len()
)
t!("skill_tree.locked_notice", count = skill_tree.primary_letters().len()).to_string()
}
/// Get the list of selectable branch IDs (Lowercase first, then other branches).
@@ -131,7 +129,7 @@ impl Widget for SkillTreeWidget<'_> {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Skill Tree ")
.title(t!("skill_tree.title").to_string())
.border_style(Style::default().fg(colors.accent()))
.style(Style::default().bg(colors.bg()));
let inner = block.inner(area);
@@ -139,44 +137,49 @@ impl Widget for SkillTreeWidget<'_> {
// Layout: main split (branch list + detail) and footer (adaptive height)
let branches = selectable_branches();
let h_navigate = t!("skill_tree.hint_navigate").to_string();
let h_scroll = t!("skill_tree.hint_scroll").to_string();
let h_back = t!("skill_tree.hint_back").to_string();
let h_unlock = t!("skill_tree.hint_unlock").to_string();
let h_start_drill = t!("skill_tree.hint_start_drill").to_string();
let (footer_hints, footer_notice): (Vec<&str>, Option<String>) =
if self.selected < branches.len() {
let bp = self.skill_tree.branch_progress(branches[self.selected]);
if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked {
(
vec![
"[↑↓/jk] Navigate",
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
"[q] Back",
h_navigate.as_str(),
h_scroll.as_str(),
h_back.as_str(),
],
Some(locked_branch_notice(self.skill_tree)),
)
} else if bp.status == BranchStatus::Available {
(
vec![
"[Enter] Unlock",
"[↑↓/jk] Navigate",
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
"[q] Back",
h_unlock.as_str(),
h_navigate.as_str(),
h_scroll.as_str(),
h_back.as_str(),
],
None,
)
} else if bp.status == BranchStatus::InProgress {
(
vec![
"[Enter] Start Drill",
"[↑↓/jk] Navigate",
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
"[q] Back",
h_start_drill.as_str(),
h_navigate.as_str(),
h_scroll.as_str(),
h_back.as_str(),
],
None,
)
} else {
(
vec![
"[↑↓/jk] Navigate",
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
"[q] Back",
h_navigate.as_str(),
h_scroll.as_str(),
h_back.as_str(),
],
None,
)
@@ -184,9 +187,9 @@ impl Widget for SkillTreeWidget<'_> {
} else {
(
vec![
"[↑↓/jk] Navigate",
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
"[q] Back",
h_navigate.as_str(),
h_scroll.as_str(),
h_back.as_str(),
],
None,
)
@@ -332,33 +335,35 @@ impl SkillTreeWidget<'_> {
let unlocked = self.skill_tree.branch_unlocked_count(branch_id);
let mastered_text = if confident_keys > 0 {
format!(" ({confident_keys} mastered)")
format!(" ({confident_keys} {})", t!("skill_tree.mastered"))
} else {
String::new()
};
let status_text = match bp.status {
BranchStatus::Complete => {
format!("{unlocked}/{total_keys} unlocked{mastered_text}")
format!("{unlocked}/{total_keys} {}{mastered_text}", t!("skill_tree.unlocked"))
}
BranchStatus::InProgress => {
if branch_id == BranchId::Lowercase {
format!("{unlocked}/{total_keys} unlocked{mastered_text}")
format!("{unlocked}/{total_keys} {}{mastered_text}", t!("skill_tree.unlocked"))
} else {
format!(
"Lvl {}/{} {unlocked}/{total_keys} unlocked{mastered_text}",
"{} {}/{} {unlocked}/{total_keys} {}{mastered_text}",
t!("skill_tree.lvl_prefix"),
bp.current_level + 1,
def.levels.len()
def.levels.len(),
t!("skill_tree.unlocked")
)
}
}
BranchStatus::Available => format!("0/{total_keys} unlocked"),
BranchStatus::Locked => format!("Locked 0/{total_keys}"),
BranchStatus::Available => format!("0/{total_keys} {}", t!("skill_tree.unlocked")),
BranchStatus::Locked => format!("{} 0/{total_keys}", t!("skill_tree.locked")),
};
let sel_indicator = if is_selected { "> " } else { " " };
lines.push(Line::from(vec![
Span::styled(format!("{sel_indicator}{prefix}{}", def.name), style),
Span::styled(format!("{sel_indicator}{prefix}{}", def.display_name()), style),
Span::styled(
format!(" {status_text}"),
Style::default().fg(colors.text_pending()),
@@ -381,8 +386,8 @@ impl SkillTreeWidget<'_> {
}
lines.push(Line::from(Span::styled(
format!(
" \u{2500}\u{2500} Branches (available after {} primary letters) \u{2500}\u{2500}",
self.skill_tree.primary_letters().len()
" \u{2500}\u{2500} {} \u{2500}\u{2500}",
t!("skill_tree.branches_separator", count = self.skill_tree.primary_letters().len())
),
Style::default().fg(colors.text_pending()),
)));
@@ -423,21 +428,21 @@ impl SkillTreeWidget<'_> {
let level_text = if branch_id == BranchId::Lowercase {
let unlocked = self.skill_tree.branch_unlocked_count(BranchId::Lowercase);
let total = self.skill_tree.branch_total_keys_for(BranchId::Lowercase);
format!("Unlocked {unlocked}/{total} letters")
t!("skill_tree.unlocked_letters", unlocked = unlocked, total = total).to_string()
} else {
match bp.status {
BranchStatus::InProgress => {
format!("Level {}/{}", bp.current_level + 1, def.levels.len())
t!("skill_tree.level", current = bp.current_level + 1, total = def.levels.len()).to_string()
}
BranchStatus::Complete => {
format!("Level {}/{}", def.levels.len(), def.levels.len())
t!("skill_tree.level", current = def.levels.len(), total = def.levels.len()).to_string()
}
_ => format!("Level 0/{}", def.levels.len()),
_ => t!("skill_tree.level_zero", total = def.levels.len()).to_string(),
}
};
lines.push(Line::from(vec![
Span::styled(
format!(" {}", def.name),
format!(" {}", def.display_name()),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
@@ -462,18 +467,20 @@ impl SkillTreeWidget<'_> {
};
for (level_idx, level) in def.levels.iter().enumerate() {
let level_status =
if bp.status == BranchStatus::Complete || level_idx < bp.current_level {
"complete"
} else if bp.status == BranchStatus::InProgress && level_idx == bp.current_level {
"in progress"
} else {
"locked"
};
let level_is_locked = !(bp.status == BranchStatus::Complete || level_idx < bp.current_level
|| (bp.status == BranchStatus::InProgress && level_idx == bp.current_level));
let level_status_owned = if bp.status == BranchStatus::Complete || level_idx < bp.current_level {
t!("skill_tree.complete").to_string()
} else if bp.status == BranchStatus::InProgress && level_idx == bp.current_level {
t!("skill_tree.in_progress").to_string()
} else {
t!("skill_tree.locked_status").to_string()
};
let level_status = level_status_owned.as_str();
// Level header
lines.push(Line::from(Span::styled(
format!(" L{}: {} ({level_status})", level_idx + 1, level.name),
format!(" L{}: {} ({level_status})", level_idx + 1, level.display_name()),
Style::default().fg(colors.fg()),
)));
@@ -492,7 +499,7 @@ impl SkillTreeWidget<'_> {
let is_locked = if branch_id == BranchId::Lowercase {
!lowercase_unlocked_keys.contains(&key)
} else {
level_status == "locked"
level_is_locked
};
let display = if key == '\n' {
@@ -509,7 +516,7 @@ impl SkillTreeWidget<'_> {
format!(" {display} "),
Style::default().fg(colors.text_pending()),
),
Span::styled("locked", Style::default().fg(colors.text_pending())),
Span::styled(t!("skill_tree.locked_status").to_string(), Style::default().fg(colors.text_pending())),
]));
} else {
let bar_width = 10;
@@ -517,7 +524,7 @@ impl SkillTreeWidget<'_> {
let empty = bar_width - filled;
let bar = format!("{}{}", "\u{2588}".repeat(filled), "\u{2591}".repeat(empty));
let pct_str = format!("{:>3.0}%", confidence * 100.0);
let focus_label = if is_focused { " in focus" } else { "" };
let focus_label = if is_focused { t!("skill_tree.in_focus").to_string() } else { String::new() };
let key_style = if is_focused {
Style::default()

View File

@@ -12,6 +12,7 @@ use crate::keyboard::display::{self, BACKSPACE, ENTER, MODIFIER_SENTINELS, SPACE
use crate::keyboard::model::KeyboardModel;
use crate::session::result::DrillResult;
use crate::ui::components::activity_heatmap::ActivityHeatmap;
use crate::i18n::t;
use crate::ui::layout::pack_hint_lines;
use crate::ui::theme::Theme;
@@ -95,8 +96,9 @@ impl Widget for StatsDashboard<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let title = t!("stats.title");
let block = Block::bordered()
.title(" Statistics ")
.title(title.as_ref())
.border_style(Style::default().fg(colors.accent()))
.style(Style::default().bg(colors.bg()));
let inner = block.inner(area);
@@ -104,7 +106,7 @@ impl Widget for StatsDashboard<'_> {
if self.history.is_empty() {
let msg = Paragraph::new(Line::from(Span::styled(
"No drills completed yet. Start typing!",
t!("stats.empty").to_string(),
Style::default().fg(colors.text_pending()),
)));
msg.render(inner, buf);
@@ -113,10 +115,11 @@ impl Widget for StatsDashboard<'_> {
// Tab header — width-aware wrapping
let width = inner.width as usize;
let labels = tab_labels();
let mut tab_lines: Vec<Line> = Vec::new();
let mut current_spans: Vec<Span> = Vec::new();
let mut current_width: usize = 0;
for (i, &label) in TAB_LABELS.iter().enumerate() {
for (i, label) in labels.iter().enumerate() {
let styled_label = format!(" {label} ");
let item_width = styled_label.chars().count() + TAB_SEPARATOR.len();
if current_width > 0 && current_width + item_width > width {
@@ -141,12 +144,13 @@ impl Widget for StatsDashboard<'_> {
let tab_line_count = tab_lines.len().max(1) as u16;
// Footer — width-aware wrapping
let footer_hints: Vec<&str> = if self.active_tab == 1 {
FOOTER_HINTS_HISTORY.to_vec()
let footer_hints = if self.active_tab == 1 {
footer_hints_history()
} else {
FOOTER_HINTS_DEFAULT.to_vec()
footer_hints_default()
};
let footer_lines_vec = pack_hint_lines(&footer_hints, width);
let hint_refs: Vec<&str> = footer_hints.iter().map(|s| s.as_str()).collect();
let footer_lines_vec = pack_hint_lines(&hint_refs, width);
let footer_line_count = footer_lines_vec.len().max(1) as u16;
let layout = Layout::default()
@@ -179,7 +183,8 @@ impl Widget for StatsDashboard<'_> {
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
let idx = self.history.len().saturating_sub(self.history_selected);
let dialog_text = format!("Delete session #{idx}? (y/n)");
let dialog_text = t!("stats.delete_confirm", idx = idx);
let confirm_title = t!("stats.confirm_title");
Clear.render(dialog_area, buf);
let dialog = Paragraph::new(vec![
@@ -192,7 +197,7 @@ impl Widget for StatsDashboard<'_> {
.style(Style::default().bg(colors.bg()))
.block(
Block::bordered()
.title(" Confirm ")
.title(confirm_title.as_ref())
.border_style(Style::default().fg(colors.error()))
.style(Style::default().bg(colors.bg())),
);
@@ -281,9 +286,10 @@ impl StatsDashboard<'_> {
let avg_acc_str = format!("{avg_accuracy:.1}%");
let time_str = format_duration(total_time);
let summary_title = t!("stats.summary_title");
let summary_block = Block::bordered()
.title(Line::from(Span::styled(
" Summary ",
summary_title.to_string(),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
@@ -292,18 +298,23 @@ impl StatsDashboard<'_> {
let summary_inner = summary_block.inner(layout[0]);
summary_block.render(layout[0], buf);
let drills_label = t!("stats.drills");
let avg_wpm_label = t!("stats.avg_wpm");
let best_wpm_label = t!("stats.best_wpm");
let accuracy_label = t!("stats.accuracy_label");
let total_time_label = t!("stats.total_time");
let summary = vec![
Line::from(vec![
Span::styled(" Drills: ", Style::default().fg(colors.fg())),
Span::styled(drills_label.to_string(), Style::default().fg(colors.fg())),
Span::styled(
&*total_str,
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
),
Span::styled(" Avg WPM: ", Style::default().fg(colors.fg())),
Span::styled(avg_wpm_label.to_string(), Style::default().fg(colors.fg())),
Span::styled(&*avg_wpm_str, Style::default().fg(colors.accent())),
Span::styled(" Best WPM: ", Style::default().fg(colors.fg())),
Span::styled(best_wpm_label.to_string(), Style::default().fg(colors.fg())),
Span::styled(
&*best_wpm_str,
Style::default()
@@ -312,7 +323,7 @@ impl StatsDashboard<'_> {
),
]),
Line::from(vec![
Span::styled(" Accuracy: ", Style::default().fg(colors.fg())),
Span::styled(accuracy_label.to_string(), Style::default().fg(colors.fg())),
Span::styled(
&*avg_acc_str,
Style::default().fg(if avg_accuracy >= 95.0 {
@@ -323,7 +334,7 @@ impl StatsDashboard<'_> {
colors.error()
}),
),
Span::styled(" Total time: ", Style::default().fg(colors.fg())),
Span::styled(total_time_label.to_string(), Style::default().fg(colors.fg())),
Span::styled(&*time_str, Style::default().fg(colors.text_pending())),
]),
];
@@ -345,10 +356,10 @@ impl StatsDashboard<'_> {
fn render_wpm_bar_graph(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let target_label = format!(" WPM per Drill (Last 20, Target: {}) ", self.target_wpm);
let target_label = t!("stats.wpm_chart_title", target = self.target_wpm);
let block = Block::bordered()
.title(Line::from(Span::styled(
target_label,
target_label.to_string(),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
@@ -490,7 +501,7 @@ impl StatsDashboard<'_> {
if data.is_empty() {
let block = Block::bordered()
.title(Line::from(Span::styled(
" Accuracy % (Last 50 Drills) ",
t!("stats.accuracy_chart_title").to_string(),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
@@ -514,7 +525,7 @@ impl StatsDashboard<'_> {
.block(
Block::bordered()
.title(Line::from(Span::styled(
" Accuracy % (Last 50 Drills) ",
t!("stats.accuracy_chart_title").to_string(),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
@@ -524,13 +535,13 @@ impl StatsDashboard<'_> {
)
.x_axis(
Axis::default()
.title("Drill #")
.title(t!("stats.chart_drill").to_string())
.style(Style::default().fg(colors.text_pending()).bg(colors.bg()))
.bounds([0.0, max_x]),
)
.y_axis(
Axis::default()
.title("Accuracy %")
.title(t!("stats.chart_accuracy_pct").to_string())
.style(Style::default().fg(colors.text_pending()).bg(colors.bg()))
.labels(vec![
Span::styled(
@@ -575,7 +586,7 @@ impl StatsDashboard<'_> {
} else {
colors.accent()
};
let wpm_label = format!(" WPM: {avg_wpm:.0}/{} ({wpm_pct:.0}%)", self.target_wpm);
let wpm_label = t!("stats.wpm_label", avg = format!("{avg_wpm:.0}"), target = self.target_wpm, pct = format!("{wpm_pct:.0}")).to_string();
render_text_bar(
&wpm_label,
wpm_pct / 100.0,
@@ -587,7 +598,7 @@ impl StatsDashboard<'_> {
// Accuracy progress
let acc_pct = avg_accuracy.min(100.0);
let acc_label = format!(" Acc: {acc_pct:.1}%");
let acc_label = t!("stats.acc_label", pct = format!("{acc_pct:.1}")).to_string();
let acc_color = if acc_pct >= 95.0 {
colors.success()
} else if acc_pct >= 85.0 {
@@ -610,10 +621,7 @@ impl StatsDashboard<'_> {
} else {
0.0
};
let level_label = format!(
" Keys: {}/{} ({} mastered)",
self.overall_unlocked, self.overall_total, self.overall_mastered
);
let level_label = t!("stats.keys_label", unlocked = self.overall_unlocked, total = self.overall_total, mastered = self.overall_mastered).to_string();
render_text_bar(
&level_label,
key_pct,
@@ -628,9 +636,10 @@ impl StatsDashboard<'_> {
let colors = &self.theme.colors;
// Recent tests bordered table
let sessions_title = t!("stats.sessions_title");
let table_block = Block::bordered()
.title(Line::from(Span::styled(
" Recent Sessions ",
sessions_title.to_string(),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
@@ -640,7 +649,7 @@ impl StatsDashboard<'_> {
table_block.render(area, buf);
let header = Line::from(vec![Span::styled(
" # WPM Raw Acc% Time Date/Time Mode Ranked Partial",
t!("stats.session_header").to_string(),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
@@ -649,7 +658,7 @@ impl StatsDashboard<'_> {
let mut lines = vec![
header,
Line::from(Span::styled(
" ─────────────────────────────────────────────────────────────────────",
t!("stats.session_separator").to_string(),
Style::default().fg(colors.border()),
)),
];
@@ -680,7 +689,8 @@ impl StatsDashboard<'_> {
" "
};
let rank_str = if result.ranked { "yes" } else { "no" };
let rank_label = if result.ranked { t!("stats.yes") } else { t!("stats.no") };
let rank_str = rank_label.as_ref();
let partial_pct = if result.partial {
result.completion_percent
} else {
@@ -721,9 +731,10 @@ impl StatsDashboard<'_> {
fn render_keyboard_heatmap(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let kbd_acc_title = t!("stats.keyboard_accuracy_title");
let block = Block::bordered()
.title(Line::from(Span::styled(
" Keyboard Accuracy % ",
kbd_acc_title.to_string(),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
@@ -876,9 +887,10 @@ impl StatsDashboard<'_> {
fn render_keyboard_timing(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let kbd_timing_title = t!("stats.keyboard_timing_title");
let block = Block::bordered()
.title(Line::from(Span::styled(
" Keyboard Timing (ms) ",
kbd_timing_title.to_string(),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
@@ -997,9 +1009,10 @@ impl StatsDashboard<'_> {
fn render_slowest_keys(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let slowest_title = t!("stats.slowest_keys_title");
let block = Block::bordered()
.title(Line::from(Span::styled(
" Slowest Keys (ms) ",
slowest_title.to_string(),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
@@ -1045,9 +1058,10 @@ impl StatsDashboard<'_> {
fn render_fastest_keys(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let fastest_title = t!("stats.fastest_keys_title");
let block = Block::bordered()
.title(Line::from(Span::styled(
" Fastest Keys (ms) ",
fastest_title.to_string(),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
@@ -1093,9 +1107,10 @@ impl StatsDashboard<'_> {
fn render_worst_accuracy_keys(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let worst_title = t!("stats.worst_accuracy_title");
let block = Block::bordered()
.title(Line::from(Span::styled(
" Worst Accuracy (%) ",
worst_title.to_string(),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
@@ -1134,10 +1149,11 @@ impl StatsDashboard<'_> {
key_accuracies.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap().then_with(|| a.0.cmp(&b.0)));
if key_accuracies.is_empty() {
let no_data = t!("stats.not_enough_data");
buf.set_string(
inner.x,
inner.y,
" Not enough data",
no_data.as_ref(),
Style::default().fg(colors.text_pending()),
);
return;
@@ -1173,9 +1189,10 @@ impl StatsDashboard<'_> {
fn render_best_accuracy_keys(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let best_title = t!("stats.best_accuracy_title");
let block = Block::bordered()
.title(Line::from(Span::styled(
" Best Accuracy (%) ",
best_title.to_string(),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
@@ -1211,10 +1228,11 @@ impl StatsDashboard<'_> {
key_accuracies.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap().then_with(|| a.0.cmp(&b.0)));
if key_accuracies.is_empty() {
let no_data = t!("stats.not_enough_data");
buf.set_string(
inner.x,
inner.y,
" Not enough data",
no_data.as_ref(),
Style::default().fg(colors.text_pending()),
);
return;
@@ -1249,9 +1267,10 @@ impl StatsDashboard<'_> {
fn render_activity_stats(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let streaks_title = t!("stats.streaks_title");
let block = Block::bordered()
.title(Line::from(Span::styled(
" Streaks ",
streaks_title.to_string(),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
@@ -1272,22 +1291,25 @@ impl StatsDashboard<'_> {
let mut top_days: Vec<(chrono::NaiveDate, usize)> = day_counts.into_iter().collect();
top_days.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| b.0.cmp(&a.0)));
let current_label = t!("stats.current_streak");
let best_label = t!("stats.best_streak");
let active_days_label = t!("stats.active_days");
let mut lines = vec![Line::from(vec![
Span::styled(" Current: ", Style::default().fg(colors.fg())),
Span::styled(current_label.to_string(), Style::default().fg(colors.fg())),
Span::styled(
format!("{current_streak}d"),
Style::default()
.fg(colors.success())
.add_modifier(Modifier::BOLD),
),
Span::styled(" Best: ", Style::default().fg(colors.fg())),
Span::styled(best_label.to_string(), Style::default().fg(colors.fg())),
Span::styled(
format!("{best_streak}d"),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
),
Span::styled(" Active Days: ", Style::default().fg(colors.fg())),
Span::styled(active_days_label.to_string(), Style::default().fg(colors.fg())),
Span::styled(
format!("{active_days_count}"),
Style::default().fg(colors.text_pending()),
@@ -1295,14 +1317,14 @@ impl StatsDashboard<'_> {
])];
let top_days_text = if top_days.is_empty() {
" Top Days: none".to_string()
t!("stats.top_days_none").to_string()
} else {
let parts: Vec<String> = top_days
.iter()
.take(3)
.map(|(d, c)| format!("{} ({})", d.format("%Y-%m-%d"), c))
.collect();
format!(" Top Days: {}", parts.join(" | "))
t!("stats.top_days", days = parts.join(" | ")).to_string()
};
lines.push(Line::from(Span::styled(
top_days_text,
@@ -1321,7 +1343,7 @@ impl StatsDashboard<'_> {
Some(d) => d,
None => {
let msg = Paragraph::new(Line::from(Span::styled(
"Complete some adaptive drills to see n-gram data",
t!("stats.ngram_empty").to_string(),
Style::default().fg(colors.text_pending()),
)));
msg.render(area, buf);
@@ -1370,9 +1392,10 @@ impl StatsDashboard<'_> {
fn render_ngram_focus(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let focus_title = t!("stats.focus_title");
let block = Block::bordered()
.title(Line::from(Span::styled(
" Active Focus ",
focus_title.to_string(),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
@@ -1392,16 +1415,16 @@ impl StatsDashboard<'_> {
let bigram_label = format!("\"{}{}\"", key.0[0], key.0[1]);
// Line 1: both focuses
lines.push(Line::from(vec![
Span::styled(" Focus: ", Style::default().fg(colors.fg())),
Span::styled(t!("stats.focus_char_label").to_string(), Style::default().fg(colors.fg())),
Span::styled(
format!("Char '{ch}'"),
t!("stats.focus_char_value", ch = ch).to_string(),
Style::default()
.fg(colors.focused_key())
.add_modifier(Modifier::BOLD),
),
Span::styled(" + ", Style::default().fg(colors.fg())),
Span::styled(t!("stats.focus_plus").to_string(), Style::default().fg(colors.fg())),
Span::styled(
format!("Bigram {bigram_label}"),
t!("stats.focus_bigram_value", label = &bigram_label).to_string(),
Style::default()
.fg(colors.focused_key())
.add_modifier(Modifier::BOLD),
@@ -1410,23 +1433,21 @@ impl StatsDashboard<'_> {
// Line 2: details
if inner.height >= 2 {
let type_label = match anomaly_type {
AnomalyType::Error => "error",
AnomalyType::Speed => "speed",
AnomalyType::Error => t!("stats.anomaly_error").to_string(),
AnomalyType::Speed => t!("stats.anomaly_speed").to_string(),
};
let detail = format!(
" Char '{ch}': weakest key | Bigram {bigram_label}: {type_label} anomaly {anomaly_pct:.0}%"
);
let detail = t!("stats.focus_detail_both", ch = ch, label = &bigram_label, r#type = &type_label, pct = format!("{anomaly_pct:.0}"));
lines.push(Line::from(Span::styled(
detail,
detail.to_string(),
Style::default().fg(colors.text_pending()),
)));
}
}
(Some(ch), None) => {
lines.push(Line::from(vec![
Span::styled(" Focus: ", Style::default().fg(colors.fg())),
Span::styled(t!("stats.focus_char_label").to_string(), Style::default().fg(colors.fg())),
Span::styled(
format!("Char '{ch}'"),
t!("stats.focus_char_value", ch = ch).to_string(),
Style::default()
.fg(colors.focused_key())
.add_modifier(Modifier::BOLD),
@@ -1434,7 +1455,7 @@ impl StatsDashboard<'_> {
]));
if inner.height >= 2 {
lines.push(Line::from(Span::styled(
format!(" Char '{ch}': weakest key, no confirmed bigram anomalies"),
t!("stats.focus_detail_char_only", ch = ch).to_string(),
Style::default().fg(colors.text_pending()),
)));
}
@@ -1442,26 +1463,26 @@ impl StatsDashboard<'_> {
(None, Some((key, anomaly_pct, anomaly_type))) => {
let bigram_label = format!("\"{}{}\"", key.0[0], key.0[1]);
let type_label = match anomaly_type {
AnomalyType::Error => "error",
AnomalyType::Speed => "speed",
AnomalyType::Error => t!("stats.anomaly_error").to_string(),
AnomalyType::Speed => t!("stats.anomaly_speed").to_string(),
};
lines.push(Line::from(vec![
Span::styled(" Focus: ", Style::default().fg(colors.fg())),
Span::styled(t!("stats.focus_char_label").to_string(), Style::default().fg(colors.fg())),
Span::styled(
format!("Bigram {bigram_label}"),
t!("stats.focus_bigram_value", label = &bigram_label).to_string(),
Style::default()
.fg(colors.focused_key())
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" ({type_label} anomaly: {anomaly_pct:.0}%)"),
t!("stats.focus_detail_bigram_only", r#type = &type_label, pct = format!("{anomaly_pct:.0}")).to_string(),
Style::default().fg(colors.text_pending()),
),
]));
}
(None, None) => {
lines.push(Line::from(Span::styled(
" Complete some adaptive drills to see focus data",
t!("stats.focus_empty").to_string(),
Style::default().fg(colors.text_pending()),
)));
}
@@ -1512,14 +1533,14 @@ impl StatsDashboard<'_> {
// Speed table: Bigram Anom% Speed Smp Strk
let header = if narrow {
if is_speed {
" Bgrm Speed Expct Anom%"
t!("stats.ngram_header_speed_narrow").to_string()
} else {
" Bgrm Err Smp Rate Exp Anom%"
t!("stats.ngram_header_error_narrow").to_string()
}
} else if is_speed {
" Bigram Speed Expect Samples Anom%"
t!("stats.ngram_header_speed").to_string()
} else {
" Bigram Errors Samples Rate Expect Anom%"
t!("stats.ngram_header_error").to_string()
};
buf.set_string(
inner.x,
@@ -1586,10 +1607,11 @@ impl StatsDashboard<'_> {
}
fn render_error_anomalies(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
let title = format!(" Error Anomalies ({}) ", data.error_anomalies.len());
let title = t!("stats.error_anomalies_title", count = data.error_anomalies.len());
let empty_msg = t!("stats.no_error_anomalies");
self.render_anomaly_panel(
&title,
" No error anomalies detected",
title.as_ref(),
empty_msg.as_ref(),
&data.error_anomalies,
false,
area,
@@ -1598,10 +1620,11 @@ impl StatsDashboard<'_> {
}
fn render_speed_anomalies(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
let title = format!(" Speed Anomalies ({}) ", data.speed_anomalies.len());
let title = t!("stats.speed_anomalies_title", count = data.speed_anomalies.len());
let empty_msg = t!("stats.no_speed_anomalies");
self.render_anomaly_panel(
&title,
" No speed anomalies detected",
title.as_ref(),
empty_msg.as_ref(),
&data.speed_anomalies,
true,
area,
@@ -1619,18 +1642,18 @@ impl StatsDashboard<'_> {
};
// Build segments from most to least important, progressively drop from the right
let scope = format!(" {}", data.scope_label);
let bigrams = format!(" | Bi: {}", data.total_bigrams);
let trigrams = format!(" | Tri: {}", data.total_trigrams);
let hesitation = format!(" | Hes: >{:.0}ms", data.hesitation_threshold_ms);
let gain = format!(" | Gain: {}", gain_str);
let gain_note = if data.latest_trigram_gain.is_none() {
" (every 50)"
let scope = t!("stats.scope_label_prefix", ).to_string() + &data.scope_label;
let bigrams = t!("stats.bi_label", count = data.total_bigrams).to_string();
let trigrams = t!("stats.tri_label", count = data.total_trigrams).to_string();
let hesitation = t!("stats.hes_label", ms = format!("{:.0}", data.hesitation_threshold_ms)).to_string();
let gain = t!("stats.gain_label", value = &gain_str).to_string();
let gain_note_str = if data.latest_trigram_gain.is_none() {
t!("stats.gain_interval").to_string()
} else {
""
String::new()
};
let segments: &[&str] = &[&scope, &bigrams, &trigrams, &hesitation, &gain, gain_note];
let segments: &[&str] = &[&scope, &bigrams, &trigrams, &hesitation, &gain, &gain_note_str];
let mut line = String::new();
for seg in segments {
if line.len() + seg.len() <= w {
@@ -1649,33 +1672,47 @@ impl StatsDashboard<'_> {
}
}
const TAB_LABELS: [&str; 6] = [
"[1] Dashboard",
"[2] History",
"[3] Activity",
"[4] Accuracy",
"[5] Timing",
"[6] N-grams",
];
fn tab_labels() -> Vec<String> {
vec![
t!("stats.tab_dashboard").to_string(),
t!("stats.tab_history").to_string(),
t!("stats.tab_activity").to_string(),
t!("stats.tab_accuracy").to_string(),
t!("stats.tab_timing").to_string(),
t!("stats.tab_ngrams").to_string(),
]
}
const TAB_SEPARATOR: &str = " ";
const FOOTER_HINTS_DEFAULT: [&str; 3] = ["[ESC] Back", "[Tab] Next tab", "[1-6] Switch tab"];
const FOOTER_HINTS_HISTORY: [&str; 6] = [
"[ESC] Back",
"[Tab] Next tab",
"[1-6] Switch tab",
"[j/k] Navigate",
"[PgUp/PgDn] Page",
"[x] Delete",
];
fn footer_hints_default() -> Vec<String> {
vec![
t!("stats.hint_back").to_string(),
t!("stats.hint_next_tab").to_string(),
t!("stats.hint_switch_tab").to_string(),
]
}
fn footer_hints_history() -> Vec<String> {
vec![
t!("stats.hint_back").to_string(),
t!("stats.hint_next_tab").to_string(),
t!("stats.hint_switch_tab").to_string(),
t!("stats.hint_navigate").to_string(),
t!("stats.hint_page").to_string(),
t!("stats.hint_delete").to_string(),
]
}
fn history_visible_rows(table_inner: Rect) -> usize {
table_inner.height.saturating_sub(2) as usize
}
fn wrapped_tab_line_count(width: usize) -> usize {
let labels = tab_labels();
let mut lines = 1usize;
let mut current_width = 0usize;
for label in TAB_LABELS {
for label in &labels {
let item_width = format!(" {label} ").chars().count() + TAB_SEPARATOR.len();
if current_width > 0 && current_width + item_width > width {
lines += 1;
@@ -1687,7 +1724,9 @@ fn wrapped_tab_line_count(width: usize) -> usize {
}
fn footer_line_count_for_history(width: usize) -> usize {
pack_hint_lines(&FOOTER_HINTS_HISTORY, width).len().max(1)
let hints = footer_hints_history();
let hint_refs: Vec<&str> = hints.iter().map(|s| s.as_str()).collect();
pack_hint_lines(&hint_refs, width).len().max(1)
}
pub fn history_page_size_for_terminal(width: u16, height: u16) -> usize {

View File

@@ -6,6 +6,7 @@ use ratatui::widgets::{Block, Paragraph, Widget};
use crate::session::drill::DrillState;
use crate::session::result::DrillResult;
use crate::i18n::t;
use crate::ui::theme::Theme;
pub struct StatsSidebar<'a> {
@@ -80,21 +81,30 @@ impl Widget for StatsSidebar<'_> {
let incorrect_str = format!("{incorrect}");
let elapsed_str = format!("{elapsed:.1}s");
let wpm_label = t!("sidebar.wpm");
let target_label = t!("sidebar.target");
let target_wpm_val = t!("sidebar.target_wpm", wpm = self.target_wpm);
let accuracy_label = t!("sidebar.accuracy");
let progress_label = t!("sidebar.progress");
let correct_label = t!("sidebar.correct");
let errors_label = t!("sidebar.errors");
let time_label = t!("sidebar.time");
let lines = vec![
Line::from(vec![
Span::styled("WPM: ", Style::default().fg(colors.fg())),
Span::styled(wpm_label.as_ref(), Style::default().fg(colors.fg())),
Span::styled(wpm_str, Style::default().fg(colors.accent())),
]),
Line::from(vec![
Span::styled("Target: ", Style::default().fg(colors.fg())),
Span::styled(target_label.as_ref(), Style::default().fg(colors.fg())),
Span::styled(
format!("{} WPM", self.target_wpm),
target_wpm_val.to_string(),
Style::default().fg(colors.text_pending()),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Accuracy: ", Style::default().fg(colors.fg())),
Span::styled(accuracy_label.as_ref(), Style::default().fg(colors.fg())),
Span::styled(
acc_str,
Style::default().fg(if accuracy >= 95.0 {
@@ -108,27 +118,28 @@ impl Widget for StatsSidebar<'_> {
]),
Line::from(""),
Line::from(vec![
Span::styled("Progress: ", Style::default().fg(colors.fg())),
Span::styled(progress_label.as_ref(), Style::default().fg(colors.fg())),
Span::styled(prog_str, Style::default().fg(colors.accent())),
]),
Line::from(""),
Line::from(vec![
Span::styled("Correct: ", Style::default().fg(colors.fg())),
Span::styled(correct_label.as_ref(), Style::default().fg(colors.fg())),
Span::styled(correct_str, Style::default().fg(colors.success())),
]),
Line::from(vec![
Span::styled("Errors: ", Style::default().fg(colors.fg())),
Span::styled(errors_label.as_ref(), Style::default().fg(colors.fg())),
Span::styled(incorrect_str, Style::default().fg(colors.error())),
]),
Line::from(""),
Line::from(vec![
Span::styled("Time: ", Style::default().fg(colors.fg())),
Span::styled(time_label.as_ref(), Style::default().fg(colors.fg())),
Span::styled(elapsed_str, Style::default().fg(colors.fg())),
]),
];
let stats_title = t!("sidebar.title");
let block = Block::bordered()
.title(" Stats ")
.title(stats_title.to_string())
.border_style(Style::default().fg(colors.border()))
.style(Style::default().bg(colors.bg()));
@@ -174,14 +185,20 @@ impl Widget for StatsSidebar<'_> {
colors.text_pending()
};
let wpm_label = t!("sidebar.wpm");
let vs_avg_label = t!("sidebar.vs_avg");
let accuracy_label = t!("sidebar.accuracy");
let errors_label = t!("sidebar.errors");
let time_label = t!("sidebar.time");
let mut lines = vec![Line::from(vec![
Span::styled("WPM: ", Style::default().fg(colors.fg())),
Span::styled(wpm_label.as_ref(), Style::default().fg(colors.fg())),
Span::styled(wpm_str, Style::default().fg(colors.accent())),
])];
if prior_count > 0 {
lines.push(Line::from(vec![
Span::styled(" vs avg: ", Style::default().fg(colors.text_pending())),
Span::styled(vs_avg_label.as_ref(), Style::default().fg(colors.text_pending())),
Span::styled(wpm_delta_str, Style::default().fg(wpm_delta_color)),
]));
}
@@ -189,7 +206,7 @@ impl Widget for StatsSidebar<'_> {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("Accuracy: ", Style::default().fg(colors.fg())),
Span::styled(accuracy_label.as_ref(), Style::default().fg(colors.fg())),
Span::styled(
acc_str,
Style::default().fg(if last.accuracy >= 95.0 {
@@ -203,25 +220,27 @@ impl Widget for StatsSidebar<'_> {
]));
if prior_count > 0 {
let vs_avg_label2 = t!("sidebar.vs_avg").to_string();
lines.push(Line::from(vec![
Span::styled(" vs avg: ", Style::default().fg(colors.text_pending())),
Span::styled(vs_avg_label2, Style::default().fg(colors.text_pending())),
Span::styled(acc_delta_str, Style::default().fg(acc_delta_color)),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("Errors: ", Style::default().fg(colors.fg())),
Span::styled(errors_label.as_ref(), Style::default().fg(colors.fg())),
Span::styled(errors_str, Style::default().fg(colors.error())),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("Time: ", Style::default().fg(colors.fg())),
Span::styled(time_label.as_ref(), Style::default().fg(colors.fg())),
Span::styled(time_str, Style::default().fg(colors.fg())),
]));
let last_drill_title = t!("sidebar.last_drill");
let block = Block::bordered()
.title(" Last Drill ")
.title(last_drill_title.to_string())
.border_style(Style::default().fg(colors.border()))
.style(Style::default().bg(colors.bg()));