Split up and clean up Keyboard Explorer detail stats

This commit is contained in:
2026-02-20 23:44:31 +00:00
parent 9e0411e1f4
commit 9cc8a214ad

View File

@@ -2495,204 +2495,144 @@ fn render_keyboard_detail_panel(frame: &mut ratatui::Frame, app: &App, area: Rec
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, area); frame.render_widget(block, area);
let mut lines: Vec<Line> = Vec::new();
// Finger assignment
let finger = app.keyboard_model.finger_for_char(selected); let finger = app.keyboard_model.finger_for_char(selected);
lines.push(Line::from(vec![
Span::styled(" Finger: ", Style::default().fg(colors.text_pending())),
Span::styled(
finger.description(),
Style::default().fg(colors.fg()),
),
]));
// Shift guidance for shifted characters
let is_shifted = selected.is_uppercase() let is_shifted = selected.is_uppercase()
|| matches!( || matches!(
selected, selected,
'!' | '@' | '#' | '$' | '%' | '^' | '&' | '*' | '(' | ')' | '_' | '+' '!' | '@' | '#' | '$' | '%' | '^' | '&' | '*' | '(' | ')' | '_' | '+'
| '{' | '}' | '|' | ':' | '"' | '<' | '>' | '?' | '~' | '{' | '}' | '|' | ':' | '"' | '<' | '>' | '?' | '~'
); );
if is_shifted { let shift_guidance = if is_shifted {
let shift_guidance = if finger.hand == Hand::Left { if finger.hand == Hand::Left {
"Hold Right Shift (right pinky)" "Hold Right Shift (right pinky)".to_string()
} else { } else {
"Hold Left Shift (left pinky)" "Hold Left Shift (left pinky)".to_string()
}; }
lines.push(Line::from(vec![ } else {
Span::styled(" Shift: ", Style::default().fg(colors.text_pending())), "No".to_string()
Span::styled(shift_guidance, Style::default().fg(colors.fg())), };
]));
}
// Unlocked status
let unlocked_keys = app.skill_tree.unlocked_keys(DrillScope::Global); let unlocked_keys = app.skill_tree.unlocked_keys(DrillScope::Global);
let is_unlocked = unlocked_keys.contains(&selected); let is_unlocked = unlocked_keys.contains(&selected);
lines.push(Line::from(vec![ let focus_key = app
Span::styled(" Unlocked: ", Style::default().fg(colors.text_pending())), .skill_tree
Span::styled( .focused_key(DrillScope::Global, &app.ranked_key_stats);
if is_unlocked { "Yes" } else { "No" }, let in_focus = focus_key == Some(selected);
Style::default().fg(if is_unlocked {
colors.success()
} else {
colors.text_pending()
}),
),
]));
// Mastery / confidence (overall and ranked)
let overall_confidence = app.key_stats.get_confidence(selected);
let ranked_confidence = app.ranked_key_stats.get_confidence(selected);
if overall_confidence > 0.0 || ranked_confidence > 0.0 {
let overall_pct = (overall_confidence * 100.0).min(100.0);
let ranked_pct = (ranked_confidence * 100.0).min(100.0);
lines.push(Line::from(vec![
Span::styled(" Mastery: ", Style::default().fg(colors.text_pending())),
Span::styled(
format!("overall {:>3.0}% ranked {:>3.0}%", overall_pct, ranked_pct),
Style::default().fg(colors.fg()),
),
]));
}
// Branch/Level info
if let Some((branch, level_name, position)) = find_key_branch(selected) {
lines.push(Line::from(vec![
Span::styled(" Branch: ", Style::default().fg(colors.text_pending())),
Span::styled(branch.name, Style::default().fg(colors.fg())),
]));
lines.push(Line::from(vec![
Span::styled(" Level: ", Style::default().fg(colors.text_pending())),
Span::styled(
format!("{} (key #{})", level_name, position),
Style::default().fg(colors.fg()),
),
]));
}
// Avg time / samples (overall and ranked)
let overall_stat = app.key_stats.get_stat(selected); let overall_stat = app.key_stats.get_stat(selected);
let ranked_stat = app.ranked_key_stats.get_stat(selected); let ranked_stat = app.ranked_key_stats.get_stat(selected);
if overall_stat.is_some() || ranked_stat.is_some() {
let fmt_time = |stat: Option<&crate::engine::key_stats::KeyStat>| -> String {
if let Some(stat) = stat {
if stat.sample_count > 0 {
let best = if stat.best_time_ms < f64::MAX {
stat.best_time_ms
} else {
stat.filtered_time_ms
};
return format!("{:.0}ms/{:.0}ms", stat.filtered_time_ms, best);
}
}
"No data".to_string()
};
let fmt_samples = |stat: Option<&crate::engine::key_stats::KeyStat>| -> usize {
stat.map(|s| s.sample_count).unwrap_or(0)
};
lines.push(Line::from(vec![
Span::styled(" Avg Time: ", Style::default().fg(colors.text_pending())),
Span::styled(
format!(
"overall {} ranked {}",
fmt_time(overall_stat),
fmt_time(ranked_stat)
),
Style::default().fg(colors.fg()),
),
]));
lines.push(Line::from(vec![
Span::styled(" Samples: ", Style::default().fg(colors.text_pending())),
Span::styled(
format!(
"overall {} ranked {}",
fmt_samples(overall_stat),
fmt_samples(ranked_stat)
),
Style::default().fg(colors.fg()),
),
]));
}
// Accuracy (overall and ranked) from precomputed caches
let overall_acc = app let overall_acc = app
.explorer_accuracy_cache_overall .explorer_accuracy_cache_overall
.filter(|(key, _, _)| *key == selected); .filter(|(key, _, _)| *key == selected);
let ranked_acc = app let ranked_acc = app
.explorer_accuracy_cache_ranked .explorer_accuracy_cache_ranked
.filter(|(key, _, _)| *key == selected); .filter(|(key, _, _)| *key == selected);
if overall_acc.is_some() || ranked_acc.is_some() {
let fmt_acc = |entry: Option<(char, usize, usize)>| -> String { let fmt_avg_time = |stat: Option<&crate::engine::key_stats::KeyStat>| -> String {
if let Some((_, correct, total)) = entry { if let Some(stat) = stat {
if total > 0 { if stat.sample_count > 0 {
let pct = (correct as f64 / total as f64) * 100.0; return format!("{:.0}ms", stat.filtered_time_ms);
return format!("{:.1}% ({}/{})", pct, correct, total);
}
} }
"No data".to_string() }
}; "No data".to_string()
lines.push(Line::from(vec![ };
Span::styled(" Accuracy: ", Style::default().fg(colors.text_pending())), let fmt_best_time = |stat: Option<&crate::engine::key_stats::KeyStat>| -> String {
Span::styled( if let Some(stat) = stat {
format!( if stat.sample_count > 0 {
"overall {} ranked {}", let best = if stat.best_time_ms < f64::MAX {
fmt_acc(overall_acc), stat.best_time_ms
fmt_acc(ranked_acc)
),
Style::default().fg(colors.fg()),
),
]));
}
// Ranked progression info (mirrors Skill Tree per-key bar semantics)
if is_unlocked {
let focus_key = app
.skill_tree
.focused_key(DrillScope::Global, &app.ranked_key_stats);
let in_focus = focus_key == Some(selected);
lines.push(Line::from(vec![
Span::styled(" Focus: ", Style::default().fg(colors.text_pending())),
Span::styled(
if in_focus { "In focus now" } else { "No" },
Style::default().fg(if in_focus {
colors.focused_key()
} else { } else {
colors.fg() stat.filtered_time_ms
}), };
), return format!("{best:.0}ms");
])); }
}
"No data".to_string()
};
let fmt_samples = |stat: Option<&crate::engine::key_stats::KeyStat>| -> String {
stat.map(|s| s.sample_count.to_string())
.unwrap_or_else(|| "0".to_string())
};
let fmt_acc = |entry: Option<(char, usize, usize)>| -> String {
if let Some((_, correct, total)) = entry {
if total > 0 {
let pct = (correct as f64 / total as f64) * 100.0;
return format!("{:.1}% ({}/{})", pct, correct, total);
}
}
"No data".to_string()
};
let conf = app.ranked_key_stats.get_confidence(selected).min(1.0); let (branch_name, level_name) = if let Some((branch, level, pos)) = find_key_branch(selected) {
let bar_width = 10usize; (branch.name.to_string(), format!("{level} (key #{pos})"))
let filled = (conf * bar_width as f64).round() as usize; } else {
let bar = format!( ("Unknown".to_string(), "Unknown".to_string())
"{}{}", };
"\u{2588}".repeat(filled),
"\u{2591}".repeat(bar_width.saturating_sub(filled)) // Ranked-only mastery display (same semantics as skill tree per-key progress)
); let ranked_conf = app.ranked_key_stats.get_confidence(selected).min(1.0);
let mastery_bar_width = 10usize;
let filled = (ranked_conf * mastery_bar_width as f64).round() as usize;
let mastery_bar = format!(
"{}{}",
"\u{2588}".repeat(filled),
"\u{2591}".repeat(mastery_bar_width.saturating_sub(filled))
);
let mastery_text = format!("{mastery_bar} {:>3.0}%", ranked_conf * 100.0);
let mut left_col: Vec<String> = vec![
format!("Finger: {}", finger.description()),
format!("Shift: {shift_guidance}"),
format!("Overall Avg Time: {}", fmt_avg_time(overall_stat)),
format!("Overall Best Time: {}", fmt_best_time(overall_stat)),
format!("Overall Samples: {}", fmt_samples(overall_stat)),
format!("Overall Accuracy: {}", fmt_acc(overall_acc)),
];
let mut right_col: Vec<String> = vec![
format!("Branch: {branch_name}"),
format!("Level: {level_name}"),
format!("Unlocked: {}", if is_unlocked { "Yes" } else { "No" }),
format!("In Focus?: {}", if in_focus { "Yes" } else { "No" }),
];
if is_unlocked {
right_col.push(format!("Mastery: {mastery_text}"));
} else {
right_col.push("Mastery: Locked".to_string());
}
right_col.push(format!("Ranked Avg Time: {}", fmt_avg_time(ranked_stat)));
right_col.push(format!("Ranked Best Time: {}", fmt_best_time(ranked_stat)));
right_col.push(format!("Ranked Samples: {}", fmt_samples(ranked_stat)));
right_col.push(format!("Ranked Accuracy: {}", fmt_acc(ranked_acc)));
if left_col.is_empty() {
left_col.push("No data yet".to_string());
}
if right_col.is_empty() {
right_col.push("No data yet".to_string());
}
let mut lines: Vec<Line> = Vec::new();
let split_gap = 3usize;
let left_width = inner.width.saturating_sub(split_gap as u16) as usize / 2;
let right_width = inner.width as usize - left_width.saturating_sub(0) - split_gap;
let row_count = left_col.len().max(right_col.len());
for i in 0..row_count {
let left = left_col.get(i).map(String::as_str).unwrap_or("");
let right = right_col.get(i).map(String::as_str).unwrap_or("");
let mut left_fit: String = left.chars().take(left_width).collect();
if left_fit.len() < left_width {
left_fit.push_str(&" ".repeat(left_width - left_fit.len()));
}
let right_fit: String = right.chars().take(right_width).collect();
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled(" Progress: ", Style::default().fg(colors.text_pending())), Span::styled(" ", Style::default().fg(colors.fg())),
Span::styled(bar, Style::default().fg(colors.accent())), Span::styled(left_fit, Style::default().fg(colors.fg())),
Span::styled( Span::styled(" | ", Style::default().fg(colors.border())),
format!(" {:>3.0}%", conf * 100.0), Span::styled(right_fit, Style::default().fg(colors.fg())),
Style::default().fg(colors.fg()),
),
])); ]));
} }
// If no stats at all let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
if overall_stat.is_none()
&& ranked_stat.is_none()
&& overall_acc.is_none()
&& ranked_acc.is_none()
{
lines.push(Line::from(Span::styled(
" No data yet",
Style::default().fg(colors.text_pending()),
)));
}
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, inner); frame.render_widget(paragraph, inner);
} }