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.
269 lines
9.7 KiB
Rust
269 lines
9.7 KiB
Rust
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::Style;
|
|
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> {
|
|
pub skill_tree: &'a SkillTree,
|
|
pub key_stats: &'a crate::engine::key_stats::KeyStatsStore,
|
|
pub drill_scope: DrillScope,
|
|
pub active_branches: &'a [BranchId],
|
|
pub theme: &'a Theme,
|
|
pub height: u16,
|
|
}
|
|
|
|
const MIN_BRANCH_CELL_WIDTH: usize = 28;
|
|
const BRANCH_CELL_GUTTER: usize = 1;
|
|
|
|
pub fn wrapped_branch_rows(area_width: u16, branch_count: usize) -> u16 {
|
|
if branch_count == 0 {
|
|
return 0;
|
|
}
|
|
let columns = wrapped_branch_columns(area_width, branch_count);
|
|
branch_count.div_ceil(columns) as u16
|
|
}
|
|
|
|
fn wrapped_branch_columns(area_width: u16, branch_count: usize) -> usize {
|
|
if branch_count == 0 {
|
|
return 1;
|
|
}
|
|
let width = area_width as usize;
|
|
let max_cols_by_width =
|
|
((width + BRANCH_CELL_GUTTER) / (MIN_BRANCH_CELL_WIDTH + BRANCH_CELL_GUTTER)).max(1);
|
|
max_cols_by_width.min(branch_count)
|
|
}
|
|
|
|
impl Widget for BranchProgressList<'_> {
|
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
|
let colors = &self.theme.colors;
|
|
let mut lines: Vec<Line> = Vec::new();
|
|
|
|
let drill_branch = match self.drill_scope {
|
|
DrillScope::Branch(id) => Some(id),
|
|
DrillScope::Global => None,
|
|
};
|
|
|
|
let show_all = should_render_branch_rows(self.height, self.active_branches.len());
|
|
|
|
if show_all {
|
|
let columns = wrapped_branch_columns(area.width, self.active_branches.len());
|
|
let rows = self.active_branches.len().div_ceil(columns);
|
|
let available_width = area.width as usize;
|
|
let total_gutter = BRANCH_CELL_GUTTER.saturating_mul(columns.saturating_sub(1));
|
|
let cell_width = available_width.saturating_sub(total_gutter) / columns;
|
|
|
|
for row in 0..rows {
|
|
if lines.len() as u16 >= self.height.saturating_sub(1) {
|
|
break;
|
|
}
|
|
|
|
let mut spans: Vec<Span> = Vec::new();
|
|
for col in 0..columns {
|
|
let idx = row * columns + col;
|
|
if idx >= self.active_branches.len() {
|
|
break;
|
|
}
|
|
if col > 0 {
|
|
spans.push(Span::raw(" ".repeat(BRANCH_CELL_GUTTER)));
|
|
}
|
|
let branch_id = self.active_branches[idx];
|
|
spans.extend(render_branch_cell(
|
|
branch_id,
|
|
drill_branch == Some(branch_id),
|
|
cell_width,
|
|
self.skill_tree,
|
|
self.key_stats,
|
|
self.theme,
|
|
));
|
|
}
|
|
lines.push(Line::from(spans));
|
|
}
|
|
} else if let Some(branch_id) = drill_branch {
|
|
let def = get_branch_definition(branch_id);
|
|
let total = SkillTree::branch_total_keys(branch_id);
|
|
let unlocked = self.skill_tree.branch_unlocked_count(branch_id);
|
|
let mastered = self
|
|
.skill_tree
|
|
.branch_confident_keys(branch_id, self.key_stats);
|
|
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.display_name()),
|
|
Style::default().fg(colors.accent()),
|
|
),
|
|
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
|
|
Span::styled(u_bar, Style::default().fg(colors.accent())),
|
|
Span::styled(e_bar, Style::default().fg(colors.text_pending())),
|
|
Span::styled(
|
|
format!(" {unlocked}/{total}"),
|
|
Style::default().fg(colors.text_pending()),
|
|
),
|
|
]));
|
|
}
|
|
|
|
// Overall line
|
|
if lines.len() < self.height as usize {
|
|
if should_insert_overall_separator(lines.len(), self.height as usize) {
|
|
lines.push(Line::from(""));
|
|
}
|
|
let total = self.skill_tree.total_unique_keys;
|
|
let unlocked = self.skill_tree.total_unlocked_count();
|
|
let mastered = self.skill_tree.total_confident_keys(self.key_stats);
|
|
let left_pad = if area.width >= 90 {
|
|
3
|
|
} else if area.width >= 70 {
|
|
2
|
|
} else if area.width >= 55 {
|
|
1
|
|
} else {
|
|
0
|
|
};
|
|
let right_pad = if area.width >= 75 { 2 } else { 0 };
|
|
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_mastered,
|
|
" ".repeat(right_pad)
|
|
);
|
|
let reserved = label.len() + suffix.len();
|
|
let bar_width = (area.width as usize).saturating_sub(reserved).max(6);
|
|
let (m_bar, u_bar, e_bar) =
|
|
compact_dual_bar_parts(mastered, unlocked, total, bar_width);
|
|
lines.push(Line::from(vec![
|
|
Span::styled(label, Style::default().fg(colors.fg())),
|
|
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
|
|
Span::styled(u_bar, Style::default().fg(colors.accent())),
|
|
Span::styled(e_bar, Style::default().fg(colors.text_pending())),
|
|
Span::styled(suffix, Style::default().fg(colors.text_pending())),
|
|
]));
|
|
}
|
|
|
|
let paragraph = Paragraph::new(lines);
|
|
paragraph.render(area, buf);
|
|
}
|
|
}
|
|
|
|
fn should_render_branch_rows(height: u16, active_branch_count: usize) -> bool {
|
|
active_branch_count > 0 && height > 1
|
|
}
|
|
|
|
fn should_insert_overall_separator(current_lines: usize, total_height: usize) -> bool {
|
|
current_lines > 0 && current_lines + 2 <= total_height
|
|
}
|
|
|
|
fn render_branch_cell<'a>(
|
|
branch_id: BranchId,
|
|
is_active: bool,
|
|
cell_width: usize,
|
|
skill_tree: &SkillTree,
|
|
key_stats: &crate::engine::key_stats::KeyStatsStore,
|
|
theme: &'a Theme,
|
|
) -> Vec<Span<'a>> {
|
|
let colors = &theme.colors;
|
|
let def = get_branch_definition(branch_id);
|
|
let total = SkillTree::branch_total_keys(branch_id);
|
|
let unlocked = skill_tree.branch_unlocked_count(branch_id);
|
|
let mastered = skill_tree.branch_confident_keys(branch_id, key_stats);
|
|
|
|
let prefix = if is_active { "\u{25b6} " } else { "\u{00b7} " };
|
|
let label_color = if is_active {
|
|
colors.accent()
|
|
} else {
|
|
colors.text_pending()
|
|
};
|
|
let count = format!("{unlocked}/{total}");
|
|
let name_width = if cell_width >= 34 {
|
|
14
|
|
} else if cell_width >= 30 {
|
|
12
|
|
} else {
|
|
10
|
|
};
|
|
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 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)),
|
|
Span::styled(name, Style::default().fg(label_color)),
|
|
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
|
|
Span::styled(u_bar, Style::default().fg(colors.accent())),
|
|
Span::styled(e_bar, Style::default().fg(colors.text_pending())),
|
|
Span::styled(
|
|
format!(" {count}"),
|
|
Style::default().fg(colors.text_pending()),
|
|
),
|
|
];
|
|
|
|
let used = prefix.len() + name_width + bar_width + 1 + count.len();
|
|
if cell_width > used {
|
|
spans.push(Span::raw(" ".repeat(cell_width - used)));
|
|
}
|
|
spans
|
|
}
|
|
|
|
fn truncate_and_pad(name: &str, width: usize) -> String {
|
|
let mut text: String = name.chars().take(width).collect();
|
|
let len = text.chars().count();
|
|
if len < width {
|
|
text.push_str(&" ".repeat(width - len));
|
|
}
|
|
text
|
|
}
|
|
|
|
fn compact_dual_bar_parts(
|
|
mastered: usize,
|
|
unlocked: usize,
|
|
total: usize,
|
|
width: usize,
|
|
) -> (String, String, String) {
|
|
if total == 0 {
|
|
return (String::new(), String::new(), "\u{2591}".repeat(width));
|
|
}
|
|
let mastered_cells = mastered * width / total;
|
|
let unlocked_cells = (unlocked * width / total).max(mastered_cells);
|
|
let empty_cells = width - unlocked_cells;
|
|
(
|
|
"\u{2588}".repeat(mastered_cells),
|
|
"\u{2593}".repeat(unlocked_cells - mastered_cells),
|
|
"\u{2591}".repeat(empty_cells),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn wrapped_rows_wraps_when_needed() {
|
|
assert_eq!(wrapped_branch_rows(120, 6), 2);
|
|
assert_eq!(wrapped_branch_rows(70, 6), 3);
|
|
assert_eq!(wrapped_branch_rows(50, 3), 3);
|
|
assert_eq!(wrapped_branch_rows(120, 0), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn renders_branch_rows_when_height_is_two() {
|
|
assert!(should_render_branch_rows(2, 6));
|
|
assert!(!should_render_branch_rows(1, 6));
|
|
assert!(!should_render_branch_rows(2, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn overall_separator_only_when_space_available() {
|
|
assert!(should_insert_overall_separator(1, 3));
|
|
assert!(should_insert_overall_separator(2, 4));
|
|
assert!(!should_insert_overall_separator(1, 2));
|
|
assert!(!should_insert_overall_separator(0, 4));
|
|
}
|
|
}
|