Skill tree integration + tons of random fixes

This commit is contained in:
2026-02-17 04:00:58 +00:00
parent edd2f7e6b5
commit a61ed77ed6
17 changed files with 2610 additions and 710 deletions

View File

@@ -14,6 +14,7 @@ pub struct SkillTreeWidget<'a> {
skill_tree: &'a SkillTreeEngine,
key_stats: &'a KeyStatsStore,
selected: usize,
detail_scroll: usize,
theme: &'a Theme,
}
@@ -22,20 +23,23 @@ impl<'a> SkillTreeWidget<'a> {
skill_tree: &'a SkillTreeEngine,
key_stats: &'a KeyStatsStore,
selected: usize,
detail_scroll: usize,
theme: &'a Theme,
) -> Self {
Self {
skill_tree,
key_stats,
selected,
detail_scroll,
theme,
}
}
}
/// Get the list of selectable branch IDs (all non-Lowercase branches).
/// Get the list of selectable branch IDs (Lowercase first, then other branches).
pub fn selectable_branches() -> Vec<BranchId> {
vec![
BranchId::Lowercase,
BranchId::Capitals,
BranchId::Numbers,
BranchId::ProsePunctuation,
@@ -44,6 +48,16 @@ pub fn selectable_branches() -> Vec<BranchId> {
]
}
pub fn detail_line_count(branch_id: BranchId) -> usize {
let def = get_branch_definition(branch_id);
// 1 line branch header + for each level: 1 line level header + 1 line per key
1 + def
.levels
.iter()
.map(|level| 1 + level.keys.len())
.sum::<usize>()
}
impl Widget for SkillTreeWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
@@ -57,7 +71,7 @@ impl Widget for SkillTreeWidget<'_> {
// Layout: header (2), branch list (dynamic), separator (1), detail panel (dynamic), footer (2)
let branches = selectable_branches();
let branch_list_height = 3 + branches.len() as u16 * 2 + 1; // root + separator + 5 branches
let branch_list_height = branches.len() as u16 * 2 + 1; // all branches * 2 lines + separator after Lowercase
let layout = Layout::default()
.direction(Direction::Vertical)
@@ -86,15 +100,15 @@ impl Widget for SkillTreeWidget<'_> {
let footer_text = 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 {
" Complete a-z to unlock branches "
" Complete a-z to unlock branches [\u{2191}\u{2193}/jk] Navigate [PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll [q] Back "
} else if bp.status == BranchStatus::Available || bp.status == BranchStatus::InProgress
{
" [Enter] Start Drill [\u{2191}\u{2193}/jk] Navigate [q] Back "
" [Enter] Start Drill [\u{2191}\u{2193}/jk] Navigate [PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll [q] Back "
} else {
" [\u{2191}\u{2193}/jk] Navigate [q] Back "
" [\u{2191}\u{2193}/jk] Navigate [PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll [q] Back "
}
} else {
" [\u{2191}\u{2193}/jk] Navigate [q] Back "
" [\u{2191}\u{2193}/jk] Navigate [PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll [q] Back "
};
let footer = Paragraph::new(Line::from(Span::styled(
@@ -110,72 +124,6 @@ impl SkillTreeWidget<'_> {
let colors = &self.theme.colors;
let mut lines: Vec<Line> = Vec::new();
// Root: Lowercase a-z
let lowercase_bp = self.skill_tree.branch_progress(BranchId::Lowercase);
let lowercase_def = get_branch_definition(BranchId::Lowercase);
let lowercase_total = lowercase_def
.levels
.iter()
.map(|l| l.keys.len())
.sum::<usize>();
let lowercase_confident = self
.skill_tree
.branch_confident_keys(BranchId::Lowercase, self.key_stats);
let (prefix, style) = match lowercase_bp.status {
BranchStatus::Complete => (
"\u{2605} ",
Style::default()
.fg(colors.text_correct())
.add_modifier(Modifier::BOLD),
),
BranchStatus::InProgress => (
"\u{25b6} ",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
),
_ => (" ", Style::default().fg(colors.text_pending())),
};
let status_text = match lowercase_bp.status {
BranchStatus::Complete => "COMPLETE".to_string(),
BranchStatus::InProgress => {
let unlocked = self.skill_tree.lowercase_unlocked_count();
format!("{unlocked}/{lowercase_total}")
}
_ => "LOCKED".to_string(),
};
lines.push(Line::from(vec![
Span::styled(
format!(" {prefix}{name}", name = lowercase_def.name),
style,
),
Span::styled(
format!(" {status_text} {lowercase_confident}/{lowercase_total} keys"),
Style::default().fg(colors.text_pending()),
),
]));
// Progress bar for lowercase
let pct = if lowercase_total > 0 {
lowercase_confident as f64 / lowercase_total as f64
} else {
0.0
};
lines.push(Line::from(Span::styled(
format!(" {}", progress_bar_str(pct, 30)),
style,
)));
// Separator
lines.push(Line::from(Span::styled(
" \u{2500}\u{2500} Branches (unlocked after a-z) \u{2500}\u{2500}",
Style::default().fg(colors.border()),
)));
// Branches
for (i, &branch_id) in branches.iter().enumerate() {
let bp = self.skill_tree.branch_progress(branch_id);
let def = get_branch_definition(branch_id);
@@ -188,50 +136,46 @@ impl SkillTreeWidget<'_> {
let (prefix, style) = match bp.status {
BranchStatus::Complete => (
"\u{2605} ",
if is_selected {
Style::default()
.fg(colors.text_correct())
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
Style::default()
.fg(colors.text_correct())
.add_modifier(Modifier::BOLD)
},
Style::default()
.fg(colors.text_correct())
.add_modifier(Modifier::BOLD),
),
BranchStatus::InProgress => (
"\u{25b6} ",
if is_selected {
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD)
},
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
),
BranchStatus::Available => (
" ",
if is_selected {
Style::default()
.fg(colors.fg())
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
Style::default().fg(colors.fg())
},
Style::default().fg(colors.fg()),
),
BranchStatus::Locked => (" ", Style::default().fg(colors.text_pending())),
};
let unlocked = self.skill_tree.branch_unlocked_count(branch_id);
let mastered_text = if confident_keys > 0 {
format!(" ({confident_keys} mastered)")
} else {
String::new()
};
let status_text = match bp.status {
BranchStatus::Complete => format!("COMPLETE {confident_keys}/{total_keys} keys"),
BranchStatus::InProgress => format!(
"Lvl {}/{} {confident_keys}/{total_keys} keys",
bp.current_level + 1,
def.levels.len()
),
BranchStatus::Available => format!("Available 0/{total_keys} keys"),
BranchStatus::Locked => format!("Locked 0/{total_keys} keys"),
BranchStatus::Complete => {
format!("{unlocked}/{total_keys} unlocked{mastered_text}")
}
BranchStatus::InProgress => {
if branch_id == BranchId::Lowercase {
format!("{unlocked}/{total_keys} unlocked{mastered_text}")
} else {
format!(
"Lvl {}/{} {unlocked}/{total_keys} unlocked{mastered_text}",
bp.current_level + 1,
def.levels.len()
)
}
}
BranchStatus::Available => format!("0/{total_keys} unlocked"),
BranchStatus::Locked => format!("Locked 0/{total_keys}"),
};
let sel_indicator = if is_selected { "> " } else { " " };
@@ -244,15 +188,22 @@ impl SkillTreeWidget<'_> {
),
]));
let pct = if total_keys > 0 {
confident_keys as f64 / total_keys as f64
} else {
0.0
};
lines.push(Line::from(Span::styled(
format!(" {}", progress_bar_str(pct, 30)),
style,
)));
let (mastered_bar, unlocked_bar, empty_bar) =
dual_progress_bar_parts(confident_keys, unlocked, total_keys, 30);
lines.push(Line::from(vec![
Span::styled(" ", style),
Span::styled(mastered_bar, Style::default().fg(colors.text_correct())),
Span::styled(unlocked_bar, Style::default().fg(colors.accent())),
Span::styled(empty_bar, Style::default().fg(colors.text_pending())),
]));
// Add separator after Lowercase (index 0)
if branch_id == BranchId::Lowercase {
lines.push(Line::from(Span::styled(
" \u{2500}\u{2500} Branches (unlocked after a-z) \u{2500}\u{2500}",
Style::default().fg(colors.border()),
)));
}
}
let paragraph = Paragraph::new(lines);
@@ -273,12 +224,20 @@ impl SkillTreeWidget<'_> {
let mut lines: Vec<Line> = Vec::new();
// Branch title with level info
let level_text = match bp.status {
BranchStatus::InProgress => {
format!("Level {}/{}", bp.current_level + 1, def.levels.len())
let level_text = if branch_id == BranchId::Lowercase {
let unlocked = self.skill_tree.branch_unlocked_count(BranchId::Lowercase);
let total = SkillTreeEngine::branch_total_keys(BranchId::Lowercase);
format!("Unlocked {unlocked}/{total} letters")
} else {
match bp.status {
BranchStatus::InProgress => {
format!("Level {}/{}", bp.current_level + 1, def.levels.len())
}
BranchStatus::Complete => {
format!("Level {}/{}", def.levels.len(), def.levels.len())
}
_ => format!("Level 0/{}", def.levels.len()),
}
BranchStatus::Complete => format!("Level {}/{}", def.levels.len(), def.levels.len()),
_ => format!("Level 0/{}", def.levels.len()),
};
lines.push(Line::from(vec![
Span::styled(
@@ -293,11 +252,19 @@ impl SkillTreeWidget<'_> {
),
]));
// Per-level key breakdown
// Per-level key breakdown with per-key mastery bars
let focused = self
.skill_tree
.focused_key(DrillScope::Branch(branch_id), self.key_stats);
// For Lowercase, determine which keys are unlocked
let lowercase_unlocked_keys: Vec<char> = if branch_id == BranchId::Lowercase {
self.skill_tree
.unlocked_keys(DrillScope::Branch(BranchId::Lowercase))
} else {
Vec::new()
};
for (level_idx, level) in def.levels.iter().enumerate() {
let level_status =
if bp.status == BranchStatus::Complete || level_idx < bp.current_level {
@@ -308,79 +275,111 @@ impl SkillTreeWidget<'_> {
"locked"
};
let mut key_spans: Vec<Span> = Vec::new();
key_spans.push(Span::styled(
format!(" L{}: ", level_idx + 1),
// Level header
lines.push(Line::from(Span::styled(
format!(" L{}: {} ({level_status})", level_idx + 1, level.name),
Style::default().fg(colors.fg()),
));
)));
// Per-key mastery bars
for &key in level.keys {
let is_confident = self.key_stats.get_confidence(key) >= 1.0;
let is_focused = focused == Some(key);
let confidence = self.key_stats.get_confidence(key).min(1.0);
let is_confident = confidence >= 1.0;
// For Lowercase, check if this specific key is unlocked
let is_locked = if branch_id == BranchId::Lowercase {
!lowercase_unlocked_keys.contains(&key)
} else {
level_status == "locked"
};
let display = if key == '\n' {
"\\n".to_string()
} else if key == '\t' {
"\\t".to_string()
} else {
key.to_string()
format!(" {key}")
};
let style = if is_focused {
Style::default()
.fg(colors.bg())
.bg(colors.focused_key())
.add_modifier(Modifier::BOLD)
} else if is_confident {
Style::default().fg(colors.text_correct())
} else if level_status == "locked" {
Style::default().fg(colors.text_pending())
if is_locked {
lines.push(Line::from(vec![
Span::styled(
format!(" {display} "),
Style::default().fg(colors.text_pending()),
),
Span::styled("locked", Style::default().fg(colors.text_pending())),
]));
} else {
Style::default().fg(colors.fg())
};
let bar_width = 10;
let filled = (confidence * bar_width as f64).round() as usize;
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 { "" };
key_spans.push(Span::styled(display, style));
key_spans.push(Span::raw(" "));
let key_style = if is_focused {
Style::default()
.fg(colors.bg())
.bg(colors.focused_key())
.add_modifier(Modifier::BOLD)
} else if is_confident {
Style::default().fg(colors.text_correct())
} else {
Style::default().fg(colors.fg())
};
let bar_color = if is_confident {
colors.text_correct()
} else {
colors.accent()
};
lines.push(Line::from(vec![
Span::styled(format!(" {display} "), key_style),
Span::styled(bar, Style::default().fg(bar_color)),
Span::styled(
format!(" {pct_str}"),
Style::default().fg(colors.text_pending()),
),
Span::styled(
focus_label,
Style::default()
.fg(colors.focused_key())
.add_modifier(Modifier::BOLD),
),
]));
}
}
key_spans.push(Span::styled(
format!(" ({level_status})"),
Style::default().fg(colors.text_pending()),
));
lines.push(Line::from(key_spans));
}
// Average confidence
let total_keys = def.levels.iter().map(|l| l.keys.len()).sum::<usize>();
let avg_conf = if total_keys > 0 {
let sum: f64 = def
.levels
.iter()
.flat_map(|l| l.keys.iter())
.map(|&ch| self.key_stats.get_confidence(ch).min(1.0))
.sum();
sum / total_keys as f64
} else {
0.0
};
lines.push(Line::from(Span::styled(
format!(
" Avg Confidence: {} {:.0}%",
progress_bar_str(avg_conf, 20),
avg_conf * 100.0
),
Style::default().fg(colors.text_pending()),
)));
let paragraph = Paragraph::new(lines);
let visible_height = area.height as usize;
if visible_height == 0 {
return;
}
let max_scroll = lines.len().saturating_sub(visible_height);
let scroll = self.detail_scroll.min(max_scroll);
let visible_lines: Vec<Line> = lines.into_iter().skip(scroll).take(visible_height).collect();
let paragraph = Paragraph::new(visible_lines);
paragraph.render(area, buf);
}
}
fn progress_bar_str(pct: f64, width: usize) -> String {
let filled = (pct * width as f64).round() as usize;
let empty = width.saturating_sub(filled);
format!("{}{}", "\u{2588}".repeat(filled), "\u{2591}".repeat(empty),)
fn dual_progress_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),
)
}