Skill tree integration + tons of random fixes
This commit is contained in:
@@ -3,7 +3,8 @@ use std::collections::HashMap;
|
||||
use chrono::{Datelike, NaiveDate, Utc};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Widget};
|
||||
|
||||
use crate::session::result::DrillResult;
|
||||
@@ -25,8 +26,13 @@ impl Widget for ActivityHeatmap<'_> {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Daily Activity (Sessions per Day) ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
.title(Line::from(Span::styled(
|
||||
" Daily Activity (Sessions per Day) ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
@@ -42,10 +48,11 @@ impl Widget for ActivityHeatmap<'_> {
|
||||
}
|
||||
|
||||
let today = Utc::now().date_naive();
|
||||
let end_date = today;
|
||||
// Show ~26 weeks (half a year)
|
||||
let weeks_to_show = ((inner.width as usize).saturating_sub(3)) / 2;
|
||||
let weeks_to_show = weeks_to_show.min(26);
|
||||
let start_date = today - chrono::Duration::weeks(weeks_to_show as i64);
|
||||
let start_date = end_date - chrono::Duration::weeks(weeks_to_show as i64);
|
||||
// Align to Monday
|
||||
let start_date =
|
||||
start_date - chrono::Duration::days(start_date.weekday().num_days_from_monday() as i64);
|
||||
@@ -71,7 +78,7 @@ impl Widget for ActivityHeatmap<'_> {
|
||||
// Month labels
|
||||
let mut last_month = 0u32;
|
||||
|
||||
while current_date <= today {
|
||||
while current_date <= end_date {
|
||||
let x = inner.x + 2 + col * 2;
|
||||
if x + 1 >= inner.x + inner.width {
|
||||
break;
|
||||
@@ -110,7 +117,7 @@ impl Widget for ActivityHeatmap<'_> {
|
||||
// Render 7 days in this week column
|
||||
for day_offset in 0..7u16 {
|
||||
let date = current_date + chrono::Duration::days(day_offset as i64);
|
||||
if date > today {
|
||||
if date > end_date {
|
||||
break;
|
||||
}
|
||||
let y = inner.y + 1 + day_offset;
|
||||
|
||||
133
src/ui/components/branch_progress_list.rs
Normal file
133
src/ui/components/branch_progress_list.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
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::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,
|
||||
}
|
||||
|
||||
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 = self.height > 2;
|
||||
|
||||
if show_all {
|
||||
for &branch_id in self.active_branches {
|
||||
if lines.len() as u16 >= self.height.saturating_sub(1) {
|
||||
break;
|
||||
}
|
||||
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 is_active = drill_branch == Some(branch_id);
|
||||
let prefix = if is_active {
|
||||
" \u{25b6} "
|
||||
} else {
|
||||
" \u{00b7} "
|
||||
};
|
||||
let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12);
|
||||
let name = format!("{:<14}", def.name);
|
||||
let label_color = if is_active {
|
||||
colors.accent()
|
||||
} else {
|
||||
colors.text_pending()
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(prefix, 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!(" {unlocked}/{total}"),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
]));
|
||||
}
|
||||
} 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.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 {
|
||||
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 (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<14}", "Overall"),
|
||||
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(
|
||||
format!(" {unlocked}/{total}"),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines);
|
||||
paragraph.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,8 @@ use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::widgets::{Block, Widget};
|
||||
|
||||
use crate::keyboard::finger::{self, Finger, Hand};
|
||||
use crate::keyboard::finger::{Finger, Hand};
|
||||
use crate::keyboard::model::KeyboardModel;
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
pub struct KeyboardDiagram<'a> {
|
||||
@@ -15,6 +16,8 @@ pub struct KeyboardDiagram<'a> {
|
||||
pub depressed_keys: &'a HashSet<char>,
|
||||
pub theme: &'a Theme,
|
||||
pub compact: bool,
|
||||
pub model: &'a KeyboardModel,
|
||||
pub shift_held: bool,
|
||||
}
|
||||
|
||||
impl<'a> KeyboardDiagram<'a> {
|
||||
@@ -24,6 +27,7 @@ impl<'a> KeyboardDiagram<'a> {
|
||||
unlocked_keys: &'a [char],
|
||||
depressed_keys: &'a HashSet<char>,
|
||||
theme: &'a Theme,
|
||||
model: &'a KeyboardModel,
|
||||
) -> Self {
|
||||
Self {
|
||||
focused_key,
|
||||
@@ -32,6 +36,8 @@ impl<'a> KeyboardDiagram<'a> {
|
||||
depressed_keys,
|
||||
theme,
|
||||
compact: false,
|
||||
model,
|
||||
shift_held: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,16 +45,15 @@ impl<'a> KeyboardDiagram<'a> {
|
||||
self.compact = compact;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn shift_held(mut self, shift_held: bool) -> Self {
|
||||
self.shift_held = shift_held;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
const ROWS: &[&[char]] = &[
|
||||
&['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
|
||||
&['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
|
||||
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
|
||||
];
|
||||
|
||||
fn finger_color(ch: char) -> Color {
|
||||
let assignment = finger::qwerty_finger(ch);
|
||||
fn finger_color(model: &KeyboardModel, ch: char) -> Color {
|
||||
let assignment = model.finger_for_char(ch);
|
||||
match (assignment.hand, assignment.finger) {
|
||||
(Hand::Left, Finger::Pinky) => Color::Rgb(180, 100, 100),
|
||||
(Hand::Left, Finger::Ring) => Color::Rgb(180, 140, 80),
|
||||
@@ -84,62 +89,234 @@ impl Widget for KeyboardDiagram<'_> {
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let key_width: u16 = if self.compact { 3 } else { 5 };
|
||||
let min_width: u16 = if self.compact { 21 } else { 30 };
|
||||
if self.compact {
|
||||
// Compact mode: letter rows only (rows 1-3 of the model)
|
||||
let letter_rows = self.model.letter_rows();
|
||||
let key_width: u16 = 3;
|
||||
let min_width: u16 = 21;
|
||||
|
||||
if inner.height < 3 || inner.width < min_width {
|
||||
return;
|
||||
}
|
||||
|
||||
let offsets: &[u16] = if self.compact { &[0, 1, 3] } else { &[1, 3, 5] };
|
||||
|
||||
for (row_idx, row) in ROWS.iter().enumerate() {
|
||||
let y = inner.y + row_idx as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
if inner.height < 3 || inner.width < min_width {
|
||||
return;
|
||||
}
|
||||
|
||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||
let offsets: &[u16] = &[0, 1, 3];
|
||||
|
||||
for (col_idx, &key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||
if x + key_width > inner.x + inner.width {
|
||||
for (row_idx, row) in letter_rows.iter().enumerate() {
|
||||
let y = inner.y + row_idx as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let is_depressed = self.depressed_keys.contains(&key);
|
||||
let is_unlocked = self.unlocked_keys.contains(&key);
|
||||
let is_focused = self.focused_key == Some(key);
|
||||
let is_next = self.next_key == Some(key);
|
||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||
|
||||
// Priority: depressed > next_expected > focused > unlocked > locked
|
||||
let style = if is_depressed {
|
||||
let bg = if is_unlocked {
|
||||
brighten_color(finger_color(key))
|
||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||
if x + key_width > inner.x + inner.width {
|
||||
break;
|
||||
}
|
||||
|
||||
let display_char = if self.shift_held {
|
||||
physical_key.shifted
|
||||
} else {
|
||||
brighten_color(colors.accent_dim())
|
||||
physical_key.base
|
||||
};
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(bg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_next {
|
||||
Style::default().fg(colors.bg()).bg(colors.accent())
|
||||
} else if is_focused {
|
||||
Style::default().fg(colors.bg()).bg(colors.focused_key())
|
||||
} else if is_unlocked {
|
||||
Style::default().fg(colors.fg()).bg(finger_color(key))
|
||||
} else {
|
||||
Style::default().fg(colors.text_pending()).bg(colors.bg())
|
||||
};
|
||||
let base_char = physical_key.base;
|
||||
|
||||
let display = if self.compact {
|
||||
format!("[{key}]")
|
||||
} else {
|
||||
format!("[ {key} ]")
|
||||
};
|
||||
buf.set_string(x, y, &display, style);
|
||||
let is_depressed = self.depressed_keys.contains(&base_char);
|
||||
let is_unlocked = self.unlocked_keys.contains(&display_char)
|
||||
|| self.unlocked_keys.contains(&base_char);
|
||||
let is_focused = self.focused_key == Some(display_char)
|
||||
|| self.focused_key == Some(base_char);
|
||||
let is_next =
|
||||
self.next_key == Some(display_char) || self.next_key == Some(base_char);
|
||||
|
||||
let style = key_style(
|
||||
is_depressed,
|
||||
is_next,
|
||||
is_focused,
|
||||
is_unlocked,
|
||||
base_char,
|
||||
self.model,
|
||||
colors,
|
||||
);
|
||||
|
||||
let display = format!("[{display_char}]");
|
||||
buf.set_string(x, y, &display, style);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Full mode: all 4 rows
|
||||
let key_width: u16 = 5;
|
||||
let min_width: u16 = 69;
|
||||
|
||||
if inner.height < 4 || inner.width < min_width {
|
||||
// Fallback to compact-style if too narrow for full
|
||||
let letter_rows = self.model.letter_rows();
|
||||
let key_width: u16 = 5;
|
||||
let offsets: &[u16] = &[1, 3, 5];
|
||||
|
||||
if inner.height < 3 || inner.width < 30 {
|
||||
return;
|
||||
}
|
||||
|
||||
for (row_idx, row) in letter_rows.iter().enumerate() {
|
||||
let y = inner.y + row_idx as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||
|
||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||
if x + key_width > inner.x + inner.width {
|
||||
break;
|
||||
}
|
||||
|
||||
let display_char = if self.shift_held {
|
||||
physical_key.shifted
|
||||
} else {
|
||||
physical_key.base
|
||||
};
|
||||
let base_char = physical_key.base;
|
||||
|
||||
let is_depressed = self.depressed_keys.contains(&base_char);
|
||||
let is_unlocked = self.unlocked_keys.contains(&display_char)
|
||||
|| self.unlocked_keys.contains(&base_char);
|
||||
let is_focused = self.focused_key == Some(display_char)
|
||||
|| self.focused_key == Some(base_char);
|
||||
let is_next =
|
||||
self.next_key == Some(display_char) || self.next_key == Some(base_char);
|
||||
|
||||
let style = key_style(
|
||||
is_depressed,
|
||||
is_next,
|
||||
is_focused,
|
||||
is_unlocked,
|
||||
base_char,
|
||||
self.model,
|
||||
colors,
|
||||
);
|
||||
|
||||
let display = format!("[ {display_char} ]");
|
||||
buf.set_string(x, y, &display, style);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Row offsets for full layout (staggered keyboard)
|
||||
let offsets: &[u16] = &[0, 2, 3, 4];
|
||||
|
||||
for (row_idx, row) in self.model.rows.iter().enumerate() {
|
||||
let y = inner.y + row_idx as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||
|
||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||
if x + key_width > inner.x + inner.width {
|
||||
break;
|
||||
}
|
||||
|
||||
let display_char = if self.shift_held {
|
||||
physical_key.shifted
|
||||
} else {
|
||||
physical_key.base
|
||||
};
|
||||
let base_char = physical_key.base;
|
||||
|
||||
let is_depressed = self.depressed_keys.contains(&base_char);
|
||||
let is_unlocked = self.unlocked_keys.contains(&display_char)
|
||||
|| self.unlocked_keys.contains(&base_char);
|
||||
let is_focused = self.focused_key == Some(display_char)
|
||||
|| self.focused_key == Some(base_char);
|
||||
let is_next =
|
||||
self.next_key == Some(display_char) || self.next_key == Some(base_char);
|
||||
|
||||
let style = key_style(
|
||||
is_depressed,
|
||||
is_next,
|
||||
is_focused,
|
||||
is_unlocked,
|
||||
base_char,
|
||||
self.model,
|
||||
colors,
|
||||
);
|
||||
|
||||
let display = format!("[ {display_char} ]");
|
||||
buf.set_string(x, y, &display, style);
|
||||
}
|
||||
|
||||
// Modifier labels at row edges (visual only)
|
||||
let label_style = Style::default().fg(colors.text_pending());
|
||||
let after_x = inner.x + offset + row.len() as u16 * key_width + 1;
|
||||
match row_idx {
|
||||
0 => {
|
||||
// Backspace after number row
|
||||
if after_x + 4 <= inner.x + inner.width {
|
||||
buf.set_string(after_x, y, "Bksp", label_style);
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
// Tab before top row, backslash already in row
|
||||
if offset >= 3 {
|
||||
buf.set_string(inner.x, y, "Tab", label_style);
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// Enter after home row
|
||||
if after_x + 5 <= inner.x + inner.width {
|
||||
buf.set_string(after_x, y, "Enter", label_style);
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
// Shift before and after bottom row
|
||||
if offset >= 5 {
|
||||
buf.set_string(inner.x, y, "Shft", label_style);
|
||||
}
|
||||
if after_x + 4 <= inner.x + inner.width {
|
||||
buf.set_string(after_x, y, "Shft", label_style);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key_style(
|
||||
is_depressed: bool,
|
||||
is_next: bool,
|
||||
is_focused: bool,
|
||||
is_unlocked: bool,
|
||||
base_char: char,
|
||||
model: &KeyboardModel,
|
||||
colors: &crate::ui::theme::ThemeColors,
|
||||
) -> Style {
|
||||
if is_depressed {
|
||||
let bg = if is_unlocked {
|
||||
brighten_color(finger_color(model, base_char))
|
||||
} else {
|
||||
brighten_color(colors.accent_dim())
|
||||
};
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(bg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_next {
|
||||
Style::default().fg(colors.bg()).bg(colors.accent())
|
||||
} else if is_focused {
|
||||
Style::default().fg(colors.bg()).bg(colors.focused_key())
|
||||
} else if is_unlocked {
|
||||
Style::default()
|
||||
.fg(colors.fg())
|
||||
.bg(finger_color(model, base_char))
|
||||
} else {
|
||||
Style::default().fg(colors.text_pending()).bg(colors.bg())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
pub mod activity_heatmap;
|
||||
pub mod branch_progress_list;
|
||||
pub mod chart;
|
||||
pub mod dashboard;
|
||||
pub mod keyboard_diagram;
|
||||
pub mod menu;
|
||||
pub mod progress_bar;
|
||||
pub mod skill_tree;
|
||||
pub mod stats_dashboard;
|
||||
pub mod stats_sidebar;
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::widgets::{Block, Widget};
|
||||
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
pub struct ProgressBar<'a> {
|
||||
pub label: String,
|
||||
pub ratio: f64,
|
||||
pub theme: &'a Theme,
|
||||
}
|
||||
|
||||
impl<'a> ProgressBar<'a> {
|
||||
pub fn new(label: &str, ratio: f64, theme: &'a Theme) -> Self {
|
||||
Self {
|
||||
label: label.to_string(),
|
||||
ratio: ratio.clamp(0.0, 1.0),
|
||||
theme,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for ProgressBar<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(format!(" {} ", self.label))
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
if inner.width == 0 || inner.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let filled_width = (self.ratio * inner.width as f64) as u16;
|
||||
let label = format!("{:.0}%", self.ratio * 100.0);
|
||||
|
||||
for x in inner.x..inner.x + inner.width {
|
||||
let style = if x < inner.x + filled_width {
|
||||
Style::default().fg(colors.bg()).bg(colors.bar_filled())
|
||||
} else {
|
||||
Style::default().fg(colors.fg()).bg(colors.bar_empty())
|
||||
};
|
||||
buf[(x, inner.y)].set_style(style);
|
||||
}
|
||||
|
||||
let label_x = inner.x + (inner.width.saturating_sub(label.len() as u16)) / 2;
|
||||
buf.set_string(label_x, inner.y, &label, Style::default().fg(colors.fg()));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::engine::key_stats::KeyStatsStore;
|
||||
use crate::keyboard::model::KeyboardModel;
|
||||
use crate::session::result::DrillResult;
|
||||
use crate::ui::components::activity_heatmap::ActivityHeatmap;
|
||||
use crate::ui::theme::Theme;
|
||||
@@ -17,6 +19,7 @@ pub struct StatsDashboard<'a> {
|
||||
pub theme: &'a Theme,
|
||||
pub history_selected: usize,
|
||||
pub history_confirm_delete: bool,
|
||||
pub keyboard_model: &'a KeyboardModel,
|
||||
}
|
||||
|
||||
impl<'a> StatsDashboard<'a> {
|
||||
@@ -28,6 +31,7 @@ impl<'a> StatsDashboard<'a> {
|
||||
theme: &'a Theme,
|
||||
history_selected: usize,
|
||||
history_confirm_delete: bool,
|
||||
keyboard_model: &'a KeyboardModel,
|
||||
) -> Self {
|
||||
Self {
|
||||
history,
|
||||
@@ -37,6 +41,7 @@ impl<'a> StatsDashboard<'a> {
|
||||
theme,
|
||||
history_selected,
|
||||
history_confirm_delete,
|
||||
keyboard_model,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,7 +76,13 @@ impl Widget for StatsDashboard<'_> {
|
||||
.split(inner);
|
||||
|
||||
// Tab header
|
||||
let tabs = ["[1] Dashboard", "[2] History", "[3] Keystrokes"];
|
||||
let tabs = [
|
||||
"[1] Dashboard",
|
||||
"[2] History",
|
||||
"[3] Activity",
|
||||
"[4] Accuracy",
|
||||
"[5] Timing",
|
||||
];
|
||||
let tab_spans: Vec<Span> = tabs
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -88,29 +99,14 @@ impl Widget for StatsDashboard<'_> {
|
||||
.collect();
|
||||
Paragraph::new(Line::from(tab_spans)).render(layout[0], buf);
|
||||
|
||||
// Tab content — wide mode shows two panels side by side
|
||||
let is_wide = area.width > 170;
|
||||
if is_wide {
|
||||
let panels = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(layout[1]);
|
||||
|
||||
// Left panel: active tab, Right panel: next tab
|
||||
let left_tab = self.active_tab;
|
||||
let right_tab = (self.active_tab + 1) % 3;
|
||||
|
||||
self.render_tab(left_tab, panels[0], buf);
|
||||
self.render_tab(right_tab, panels[1], buf);
|
||||
} else {
|
||||
self.render_tab(self.active_tab, layout[1], buf);
|
||||
}
|
||||
// Render only one tab at a time so each tab gets full breathing room.
|
||||
self.render_tab(self.active_tab, layout[1], buf);
|
||||
|
||||
// Footer
|
||||
let footer_text = if self.active_tab == 1 {
|
||||
" [ESC] Back [Tab] Next tab [j/k] Navigate [x] Delete"
|
||||
" [ESC] Back [Tab] Next tab [1-5] Switch tab [j/k] Navigate [x] Delete"
|
||||
} else {
|
||||
" [ESC] Back [Tab] Next tab [1/2/3] Switch tab"
|
||||
" [ESC] Back [Tab] Next tab [1-5] Switch tab"
|
||||
};
|
||||
let footer = Paragraph::new(Line::from(Span::styled(
|
||||
footer_text,
|
||||
@@ -152,11 +148,53 @@ impl StatsDashboard<'_> {
|
||||
match tab {
|
||||
0 => self.render_dashboard_tab(area, buf),
|
||||
1 => self.render_history_tab(area, buf),
|
||||
2 => self.render_keystrokes_tab(area, buf),
|
||||
2 => self.render_activity_tab(area, buf),
|
||||
3 => self.render_accuracy_tab(area, buf),
|
||||
4 => self.render_timing_tab(area, buf),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_activity_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(9), Constraint::Length(4)])
|
||||
.split(area);
|
||||
ActivityHeatmap::new(self.history, self.theme).render(layout[0], buf);
|
||||
self.render_activity_stats(layout[1], buf);
|
||||
}
|
||||
|
||||
fn render_accuracy_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let kbd_height: u16 = if area.width >= 96 { 10 } else { 8 };
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(kbd_height), Constraint::Min(6)])
|
||||
.split(area);
|
||||
self.render_keyboard_heatmap(layout[0], buf);
|
||||
let lists = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(layout[1]);
|
||||
self.render_worst_accuracy_keys(lists[0], buf);
|
||||
self.render_best_accuracy_keys(lists[1], buf);
|
||||
}
|
||||
|
||||
fn render_timing_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let kbd_height: u16 = if area.width >= 96 { 10 } else { 8 };
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(kbd_height), Constraint::Min(6)])
|
||||
.split(area);
|
||||
self.render_keyboard_timing(layout[0], buf);
|
||||
|
||||
let lists = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(layout[1]);
|
||||
self.render_slowest_keys(lists[0], buf);
|
||||
self.render_fastest_keys(lists[1], buf);
|
||||
}
|
||||
|
||||
fn render_dashboard_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
@@ -183,8 +221,13 @@ impl StatsDashboard<'_> {
|
||||
let time_str = format_duration(total_time);
|
||||
|
||||
let summary_block = Block::bordered()
|
||||
.title(" Summary ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
.title(Line::from(Span::styled(
|
||||
" Summary ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let summary_inner = summary_block.inner(layout[0]);
|
||||
summary_block.render(layout[0], buf);
|
||||
|
||||
@@ -243,8 +286,13 @@ impl StatsDashboard<'_> {
|
||||
|
||||
let target_label = format!(" WPM per Drill (Last 20, Target: {}) ", self.target_wpm);
|
||||
let block = Block::bordered()
|
||||
.title(target_label)
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
.title(Line::from(Span::styled(
|
||||
target_label,
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
@@ -376,8 +424,13 @@ impl StatsDashboard<'_> {
|
||||
|
||||
if data.is_empty() {
|
||||
let block = Block::bordered()
|
||||
.title(" Accuracy % (Last 50 Drills) ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
.title(Line::from(Span::styled(
|
||||
" Accuracy % (Last 50 Drills) ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
block.render(area, buf);
|
||||
return;
|
||||
}
|
||||
@@ -393,8 +446,13 @@ impl StatsDashboard<'_> {
|
||||
let chart = Chart::new(vec![dataset])
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title(" Accuracy % (Last 50 Drills) ")
|
||||
.border_style(Style::default().fg(colors.border())),
|
||||
.title(Line::from(Span::styled(
|
||||
" Accuracy % (Last 50 Drills) ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent())),
|
||||
)
|
||||
.x_axis(
|
||||
Axis::default()
|
||||
@@ -406,6 +464,11 @@ impl StatsDashboard<'_> {
|
||||
Axis::default()
|
||||
.title("Accuracy %")
|
||||
.style(Style::default().fg(colors.text_pending()))
|
||||
.labels(vec![
|
||||
Span::styled("80", Style::default().fg(colors.text_pending())),
|
||||
Span::styled("90", Style::default().fg(colors.text_pending())),
|
||||
Span::styled("100", Style::default().fg(colors.text_pending())),
|
||||
])
|
||||
.bounds([80.0, 100.0]),
|
||||
);
|
||||
|
||||
@@ -490,17 +553,17 @@ impl StatsDashboard<'_> {
|
||||
fn render_history_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(10), Constraint::Length(8)])
|
||||
.split(area);
|
||||
|
||||
// Recent tests bordered table
|
||||
let table_block = Block::bordered()
|
||||
.title(" Recent Sessions ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
let table_inner = table_block.inner(layout[0]);
|
||||
table_block.render(layout[0], buf);
|
||||
.title(Line::from(Span::styled(
|
||||
" Recent Sessions ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let table_inner = table_block.inner(area);
|
||||
table_block.render(area, buf);
|
||||
|
||||
let header = Line::from(vec![Span::styled(
|
||||
" # WPM Raw Acc% Time Date Mode",
|
||||
@@ -566,190 +629,88 @@ impl StatsDashboard<'_> {
|
||||
}
|
||||
|
||||
Paragraph::new(lines).render(table_inner, buf);
|
||||
|
||||
// Per-key speed distribution
|
||||
self.render_per_key_speed(layout[1], buf);
|
||||
}
|
||||
|
||||
fn render_per_key_speed(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Avg Key Time by Character ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let columns_per_row: usize = 13;
|
||||
let col_width: u16 = 4;
|
||||
let row_height: u16 = 3;
|
||||
|
||||
if inner.width < columns_per_row as u16 * col_width || inner.height < row_height {
|
||||
return;
|
||||
}
|
||||
|
||||
let letters: Vec<char> = ('a'..='z').collect();
|
||||
let row_count = if inner.height >= row_height * 2 { 2 } else { 1 };
|
||||
let max_time = letters
|
||||
.iter()
|
||||
.filter_map(|&ch| self.key_stats.stats.get(&ch))
|
||||
.map(|s| s.filtered_time_ms)
|
||||
.fold(0.0f64, f64::max)
|
||||
.max(1.0);
|
||||
|
||||
let bar_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||
|
||||
for (i, &ch) in letters.iter().take(columns_per_row * row_count).enumerate() {
|
||||
let row = i / columns_per_row;
|
||||
let col = i % columns_per_row;
|
||||
let x = inner.x + (col as u16 * col_width);
|
||||
let y = inner.y + row as u16 * row_height;
|
||||
|
||||
if x + col_width > inner.x + inner.width || y + 2 >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let time = self
|
||||
.key_stats
|
||||
.stats
|
||||
.get(&ch)
|
||||
.map(|s| s.filtered_time_ms)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let ratio = time / max_time;
|
||||
let color = if ratio < 0.3 {
|
||||
colors.success()
|
||||
} else if ratio < 0.6 {
|
||||
colors.accent()
|
||||
} else {
|
||||
colors.error()
|
||||
};
|
||||
|
||||
// Letter label
|
||||
buf.set_string(x, y, &ch.to_string(), Style::default().fg(color));
|
||||
|
||||
// Bar indicator
|
||||
let bar_char = if time > 0.0 {
|
||||
let idx = ((ratio * 7.0).round() as usize).min(7);
|
||||
bar_chars[idx]
|
||||
} else {
|
||||
' '
|
||||
};
|
||||
buf.set_string(x, y + 1, &bar_char.to_string(), Style::default().fg(color));
|
||||
|
||||
// Time label on row 3, render seconds when value exceeds 999ms.
|
||||
if time > 0.0 {
|
||||
let time_label = if time > 999.0 {
|
||||
format!("({:.0}s)", time / 1000.0)
|
||||
} else {
|
||||
format!("{time:.0}")
|
||||
};
|
||||
let label = if time_label.len() > col_width as usize {
|
||||
let start = time_label.len() - col_width as usize;
|
||||
&time_label[start..]
|
||||
} else {
|
||||
&time_label
|
||||
};
|
||||
let label_x = x + col_width.saturating_sub(label.len() as u16);
|
||||
buf.set_string(
|
||||
label_x,
|
||||
y + 2,
|
||||
label,
|
||||
Style::default().fg(colors.text_pending()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_keystrokes_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(12), // Activity heatmap
|
||||
Constraint::Length(7), // Keyboard accuracy heatmap
|
||||
Constraint::Min(5), // Slowest/Fastest/Stats
|
||||
Constraint::Length(5), // Overall stats
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Activity heatmap
|
||||
let heatmap = ActivityHeatmap::new(self.history, self.theme);
|
||||
heatmap.render(layout[0], buf);
|
||||
|
||||
// Keyboard accuracy heatmap with percentages
|
||||
self.render_keyboard_heatmap(layout[1], buf);
|
||||
|
||||
// Slowest/Fastest/Worst keys
|
||||
let key_layout = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(33),
|
||||
Constraint::Percentage(34),
|
||||
Constraint::Percentage(33),
|
||||
])
|
||||
.split(layout[2]);
|
||||
|
||||
self.render_slowest_keys(key_layout[0], buf);
|
||||
self.render_fastest_keys(key_layout[1], buf);
|
||||
self.render_worst_accuracy_keys(key_layout[2], buf);
|
||||
|
||||
// Overall stats
|
||||
self.render_overall_stats(layout[3], buf);
|
||||
}
|
||||
|
||||
fn render_keyboard_heatmap(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Keyboard Accuracy % ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
.title(Line::from(Span::styled(
|
||||
" Keyboard Accuracy % ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
if inner.height < 3 || inner.width < 50 {
|
||||
if inner.height < 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
let rows: &[&[char]] = &[
|
||||
&['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
|
||||
&['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
|
||||
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
|
||||
];
|
||||
let offsets: &[u16] = &[1, 3, 5];
|
||||
let key_width: u16 = 5; // wider to fit accuracy %
|
||||
let (key_width, key_step) = if inner.width >= required_kbd_width(5, 6) {
|
||||
(5, 6)
|
||||
} else if inner.width >= required_kbd_width(4, 5) {
|
||||
(4, 5)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let show_shifted = inner.height >= 6;
|
||||
let all_rows = &self.keyboard_model.rows;
|
||||
let offsets: &[u16] = &[0, 2, 3, 4];
|
||||
|
||||
for (row_idx, row) in rows.iter().enumerate() {
|
||||
let y = inner.y + row_idx as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
for (row_idx, row) in all_rows.iter().enumerate() {
|
||||
let base_y = if show_shifted {
|
||||
inner.y + row_idx as u16 * 2 + 1 // shifted on top, base below
|
||||
} else {
|
||||
inner.y + row_idx as u16
|
||||
};
|
||||
|
||||
if base_y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||
|
||||
for (col_idx, &key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||
// Shifted row (dimmer)
|
||||
if show_shifted {
|
||||
let shifted_y = base_y - 1;
|
||||
if shifted_y >= inner.y {
|
||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_step;
|
||||
if x + key_width > inner.x + inner.width {
|
||||
break;
|
||||
}
|
||||
|
||||
let key = physical_key.shifted;
|
||||
let accuracy = self.get_key_accuracy(key);
|
||||
let fg_color = accuracy_color(accuracy, colors);
|
||||
|
||||
let display = format_accuracy_cell(key, accuracy, key_width);
|
||||
buf.set_string(
|
||||
x,
|
||||
shifted_y,
|
||||
&display,
|
||||
Style::default().fg(fg_color).add_modifier(Modifier::DIM),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Base row
|
||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_step;
|
||||
if x + key_width > inner.x + inner.width {
|
||||
break;
|
||||
}
|
||||
|
||||
let key = physical_key.base;
|
||||
let accuracy = self.get_key_accuracy(key);
|
||||
let (fg_color, bg_color) = if accuracy <= 0.0 {
|
||||
(colors.text_pending(), colors.bg())
|
||||
} else if accuracy >= 98.0 {
|
||||
(colors.success(), colors.bg())
|
||||
} else if accuracy >= 90.0 {
|
||||
(colors.warning(), colors.bg())
|
||||
} else {
|
||||
(colors.error(), colors.bg())
|
||||
};
|
||||
let fg_color = accuracy_color(accuracy, colors);
|
||||
|
||||
let display = if accuracy > 0.0 {
|
||||
let pct = accuracy.round() as u32;
|
||||
format!("{key}{pct:>3}")
|
||||
} else {
|
||||
format!("{key} ")
|
||||
};
|
||||
buf.set_string(x, y, &display, Style::default().fg(fg_color).bg(bg_color));
|
||||
let display = format_accuracy_cell(key, accuracy, key_width);
|
||||
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -775,12 +736,106 @@ impl StatsDashboard<'_> {
|
||||
correct as f64 / total as f64 * 100.0
|
||||
}
|
||||
|
||||
fn get_key_time_ms(&self, key: char) -> f64 {
|
||||
self.key_stats
|
||||
.stats
|
||||
.get(&key)
|
||||
.filter(|s| s.sample_count > 0)
|
||||
.map(|s| s.filtered_time_ms)
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn render_keyboard_timing(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
" Keyboard Timing (ms) ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
if inner.height < 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
let (key_width, key_step) = if inner.width >= required_kbd_width(5, 6) {
|
||||
(5, 6)
|
||||
} else if inner.width >= required_kbd_width(4, 5) {
|
||||
(4, 5)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let show_shifted = inner.height >= 6;
|
||||
let all_rows = &self.keyboard_model.rows;
|
||||
let offsets: &[u16] = &[0, 2, 3, 4];
|
||||
|
||||
for (row_idx, row) in all_rows.iter().enumerate() {
|
||||
let base_y = if show_shifted {
|
||||
inner.y + row_idx as u16 * 2 + 1
|
||||
} else {
|
||||
inner.y + row_idx as u16
|
||||
};
|
||||
|
||||
if base_y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||
|
||||
if show_shifted {
|
||||
let shifted_y = base_y - 1;
|
||||
if shifted_y >= inner.y {
|
||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_step;
|
||||
if x + key_width > inner.x + inner.width {
|
||||
break;
|
||||
}
|
||||
|
||||
let key = physical_key.shifted;
|
||||
let time_ms = self.get_key_time_ms(key);
|
||||
let fg_color = timing_color(time_ms, colors);
|
||||
let display = format_timing_cell(key, time_ms, key_width);
|
||||
buf.set_string(
|
||||
x,
|
||||
shifted_y,
|
||||
&display,
|
||||
Style::default().fg(fg_color).add_modifier(Modifier::DIM),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_step;
|
||||
if x + key_width > inner.x + inner.width {
|
||||
break;
|
||||
}
|
||||
|
||||
let key = physical_key.base;
|
||||
let time_ms = self.get_key_time_ms(key);
|
||||
let fg_color = timing_color(time_ms, colors);
|
||||
let display = format_timing_cell(key, time_ms, key_width);
|
||||
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_slowest_keys(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Slowest Keys (ms) ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
.title(Line::from(Span::styled(
|
||||
" Slowest Keys (ms) ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
@@ -793,13 +848,27 @@ impl StatsDashboard<'_> {
|
||||
.collect();
|
||||
key_times.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||
|
||||
for (i, (ch, time)) in key_times.iter().take(5).enumerate() {
|
||||
let max_time = key_times.first().map(|(_, t)| *t).unwrap_or(1.0);
|
||||
|
||||
for (i, (ch, time)) in key_times.iter().take(inner.height as usize).enumerate() {
|
||||
let y = inner.y + i as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
let text = format!(" '{ch}' {time:.0}ms");
|
||||
buf.set_string(inner.x, y, &text, Style::default().fg(colors.error()));
|
||||
let label = format!(" {ch} {time:>4.0}ms ");
|
||||
let label_len = label.len() as u16;
|
||||
buf.set_string(inner.x, y, &label, Style::default().fg(colors.error()));
|
||||
let bar_space = inner.width.saturating_sub(label_len) as usize;
|
||||
if bar_space > 0 {
|
||||
let filled = ((time / max_time) * bar_space as f64).round() as usize;
|
||||
let bar = "\u{2588}".repeat(filled.min(bar_space));
|
||||
buf.set_string(
|
||||
inner.x + label_len,
|
||||
y,
|
||||
&bar,
|
||||
Style::default().fg(colors.error()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -807,8 +876,13 @@ impl StatsDashboard<'_> {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Fastest Keys (ms) ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
.title(Line::from(Span::styled(
|
||||
" Fastest Keys (ms) ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
@@ -821,13 +895,27 @@ impl StatsDashboard<'_> {
|
||||
.collect();
|
||||
key_times.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
|
||||
|
||||
for (i, (ch, time)) in key_times.iter().take(5).enumerate() {
|
||||
let max_time = key_times.last().map(|(_, t)| *t).unwrap_or(1.0);
|
||||
|
||||
for (i, (ch, time)) in key_times.iter().take(inner.height as usize).enumerate() {
|
||||
let y = inner.y + i as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
let text = format!(" '{ch}' {time:.0}ms");
|
||||
buf.set_string(inner.x, y, &text, Style::default().fg(colors.success()));
|
||||
let label = format!(" {ch} {time:>4.0}ms ");
|
||||
let label_len = label.len() as u16;
|
||||
buf.set_string(inner.x, y, &label, Style::default().fg(colors.success()));
|
||||
let bar_space = inner.width.saturating_sub(label_len) as usize;
|
||||
if bar_space > 0 && max_time > 0.0 {
|
||||
let filled = ((time / max_time) * bar_space as f64).round() as usize;
|
||||
let bar = "\u{2588}".repeat(filled.min(bar_space));
|
||||
buf.set_string(
|
||||
inner.x + label_len,
|
||||
y,
|
||||
&bar,
|
||||
Style::default().fg(colors.success()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -835,29 +923,32 @@ impl StatsDashboard<'_> {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Worst Accuracy Keys (%) ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
.title(Line::from(Span::styled(
|
||||
" Worst Accuracy (%) ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
// Compute accuracy for each key
|
||||
let mut key_accuracies: Vec<(char, f64, usize)> = ('a'..='z')
|
||||
// Collect all keys from keyboard model
|
||||
let mut all_keys = std::collections::HashSet::new();
|
||||
for row in &self.keyboard_model.rows {
|
||||
for pk in row {
|
||||
all_keys.insert(pk.base);
|
||||
all_keys.insert(pk.shifted);
|
||||
}
|
||||
}
|
||||
|
||||
let mut key_accuracies: Vec<(char, f64)> = all_keys
|
||||
.into_iter()
|
||||
.filter_map(|ch| {
|
||||
let mut correct = 0usize;
|
||||
let mut total = 0usize;
|
||||
for result in self.history {
|
||||
for kt in &result.per_key_times {
|
||||
if kt.key == ch {
|
||||
total += 1;
|
||||
if kt.correct {
|
||||
correct += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if total >= 5 {
|
||||
let acc = correct as f64 / total as f64 * 100.0;
|
||||
Some((ch, acc, total))
|
||||
let accuracy = self.get_key_accuracy(ch);
|
||||
// Only include keys with enough data and imperfect accuracy
|
||||
if accuracy > 0.0 && accuracy < 100.0 {
|
||||
Some((ch, accuracy))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -870,70 +961,242 @@ impl StatsDashboard<'_> {
|
||||
buf.set_string(
|
||||
inner.x,
|
||||
inner.y,
|
||||
" Not enough data",
|
||||
" Not enough data",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (i, (ch, acc, _total)) in key_accuracies.iter().take(5).enumerate() {
|
||||
for (i, (ch, acc)) in key_accuracies
|
||||
.iter()
|
||||
.take(inner.height as usize)
|
||||
.enumerate()
|
||||
{
|
||||
let y = inner.y + i as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
let badge = format!(" '{ch}' {acc:.1}%");
|
||||
let label = format!(" {ch} {acc:>5.1}% ");
|
||||
let label_len = label.len() as u16;
|
||||
let color = if *acc >= 95.0 {
|
||||
colors.warning()
|
||||
} else {
|
||||
colors.error()
|
||||
};
|
||||
buf.set_string(inner.x, y, &badge, Style::default().fg(color));
|
||||
buf.set_string(inner.x, y, &label, Style::default().fg(color));
|
||||
let bar_space = inner.width.saturating_sub(label_len) as usize;
|
||||
if bar_space > 0 {
|
||||
let filled = ((acc / 100.0) * bar_space as f64).round() as usize;
|
||||
let bar = "\u{2588}".repeat(filled.min(bar_space));
|
||||
buf.set_string(inner.x + label_len, y, &bar, Style::default().fg(color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_overall_stats(&self, area: Rect, buf: &mut Buffer) {
|
||||
fn render_best_accuracy_keys(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Overall Totals ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
.title(Line::from(Span::styled(
|
||||
" Best Accuracy (%) ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let total_chars: usize = self.history.iter().map(|r| r.total_chars).sum();
|
||||
let total_correct: usize = self.history.iter().map(|r| r.correct).sum();
|
||||
let total_incorrect: usize = self.history.iter().map(|r| r.incorrect).sum();
|
||||
let total_time: f64 = self.history.iter().map(|r| r.elapsed_secs).sum();
|
||||
let mut all_keys = std::collections::HashSet::new();
|
||||
for row in &self.keyboard_model.rows {
|
||||
for pk in row {
|
||||
all_keys.insert(pk.base);
|
||||
all_keys.insert(pk.shifted);
|
||||
}
|
||||
}
|
||||
|
||||
let mut key_accuracies: Vec<(char, f64)> = all_keys
|
||||
.into_iter()
|
||||
.filter_map(|ch| {
|
||||
let accuracy = self.get_key_accuracy(ch);
|
||||
if accuracy > 0.0 {
|
||||
Some((ch, accuracy))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
key_accuracies.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||
|
||||
if key_accuracies.is_empty() {
|
||||
buf.set_string(
|
||||
inner.x,
|
||||
inner.y,
|
||||
" Not enough data",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (i, (ch, acc)) in key_accuracies
|
||||
.iter()
|
||||
.take(inner.height as usize)
|
||||
.enumerate()
|
||||
{
|
||||
let y = inner.y + i as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
let label = format!(" {ch} {acc:>5.1}% ");
|
||||
let label_len = label.len() as u16;
|
||||
let color = if *acc >= 98.0 {
|
||||
colors.success()
|
||||
} else {
|
||||
colors.warning()
|
||||
};
|
||||
buf.set_string(inner.x, y, &label, Style::default().fg(color));
|
||||
let bar_space = inner.width.saturating_sub(label_len) as usize;
|
||||
if bar_space > 0 {
|
||||
let filled = ((acc / 100.0) * bar_space as f64).round() as usize;
|
||||
let bar = "\u{2588}".repeat(filled.min(bar_space));
|
||||
buf.set_string(inner.x + label_len, y, &bar, Style::default().fg(color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_activity_stats(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
let block = Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
" Streaks ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let mut active_days: BTreeSet<chrono::NaiveDate> = BTreeSet::new();
|
||||
for r in self.history {
|
||||
active_days.insert(r.timestamp.date_naive());
|
||||
}
|
||||
let (current_streak, best_streak) = compute_streaks(&active_days);
|
||||
let active_days_count = active_days.len();
|
||||
|
||||
let lines = vec![Line::from(vec![
|
||||
Span::styled(" Characters: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(" Current: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("{total_chars}"),
|
||||
Style::default().fg(colors.accent()),
|
||||
format!("{current_streak}d"),
|
||||
Style::default()
|
||||
.fg(colors.success())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" Correct: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(" Best: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("{total_correct}"),
|
||||
Style::default().fg(colors.success()),
|
||||
format!("{best_streak}d"),
|
||||
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" Errors: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(" Active Days: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("{total_incorrect}"),
|
||||
Style::default().fg(if total_incorrect > 0 {
|
||||
colors.error()
|
||||
} else {
|
||||
colors.success()
|
||||
}),
|
||||
),
|
||||
Span::styled(" Time: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format_duration(total_time),
|
||||
format!("{active_days_count}"),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
])];
|
||||
|
||||
Paragraph::new(lines).render(inner, buf);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn accuracy_color(accuracy: f64, colors: &crate::ui::theme::ThemeColors) -> ratatui::style::Color {
|
||||
if accuracy <= 0.0 {
|
||||
colors.text_pending()
|
||||
} else if accuracy >= 98.0 {
|
||||
colors.success()
|
||||
} else if accuracy >= 90.0 {
|
||||
colors.warning()
|
||||
} else {
|
||||
colors.error()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_accuracy_cell(key: char, accuracy: f64, key_width: u16) -> String {
|
||||
if accuracy > 0.0 {
|
||||
let pct = accuracy.round() as u32;
|
||||
if key_width >= 5 {
|
||||
format!("{key}{pct:>3}")
|
||||
} else {
|
||||
format!("{key}{pct:>2}")
|
||||
}
|
||||
} else if key_width >= 5 {
|
||||
format!("{key} ")
|
||||
} else {
|
||||
format!("{key} ")
|
||||
}
|
||||
}
|
||||
|
||||
fn timing_color(time_ms: f64, colors: &crate::ui::theme::ThemeColors) -> ratatui::style::Color {
|
||||
if time_ms <= 0.0 {
|
||||
colors.text_pending()
|
||||
} else if time_ms <= 200.0 {
|
||||
colors.success()
|
||||
} else if time_ms <= 400.0 {
|
||||
colors.warning()
|
||||
} else {
|
||||
colors.error()
|
||||
}
|
||||
}
|
||||
|
||||
fn required_kbd_width(key_width: u16, key_step: u16) -> u16 {
|
||||
let max_offset: u16 = 4;
|
||||
max_offset + 12 * key_step + key_width
|
||||
}
|
||||
|
||||
fn compute_streaks(active_days: &BTreeSet<chrono::NaiveDate>) -> (usize, usize) {
|
||||
if active_days.is_empty() {
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
let mut best = 1usize;
|
||||
let mut run = 1usize;
|
||||
let mut prev = None;
|
||||
for &day in active_days {
|
||||
if let Some(p) = prev {
|
||||
if day.signed_duration_since(p).num_days() == 1 {
|
||||
run += 1;
|
||||
} else {
|
||||
run = 1;
|
||||
}
|
||||
best = best.max(run);
|
||||
}
|
||||
prev = Some(day);
|
||||
}
|
||||
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let mut current = 0usize;
|
||||
let mut cursor = today;
|
||||
while active_days.contains(&cursor) {
|
||||
current += 1;
|
||||
cursor -= chrono::Duration::days(1);
|
||||
}
|
||||
|
||||
(current, best)
|
||||
}
|
||||
|
||||
fn format_timing_cell(key: char, time_ms: f64, key_width: u16) -> String {
|
||||
if time_ms > 0.0 {
|
||||
let ms = time_ms.round() as u32;
|
||||
if key_width >= 5 {
|
||||
format!("{key}{ms:>4}")
|
||||
} else {
|
||||
format!("{key}{:>3}", ms.min(999))
|
||||
}
|
||||
} else if key_width >= 5 {
|
||||
format!("{key} ")
|
||||
} else {
|
||||
format!("{key} ")
|
||||
}
|
||||
}
|
||||
|
||||
fn render_text_bar(
|
||||
|
||||
Reference in New Issue
Block a user