Implement six major improvements to typing tutor

1. Start in Adaptive Drill by default: App launches directly into a
   typing lesson instead of the menu screen.

2. Fix error tracking for backspaced corrections: Add typo_flags
   HashSet to LessonState that persists error positions through
   backspace. Errors at a position are counted even if corrected,
   matching keybr.com behavior. Multiple errors at the same position
   count as one.

3. Fix keyboard visualization with depressed keys: Enable crossterm
   keyboard enhancement flags for key Release events. Track depressed
   keys in a HashSet with 150ms fallback clearing. Depressed keys
   render with bright/bold styling at highest priority. Add compact
   keyboard mode for medium-width terminals.

4. Responsive UI for small terminals: Add LayoutTier enum (Wide >=100,
   Medium 60-99, Narrow <60 cols). Medium hides sidebar and shows
   compact stats header and compact keyboard. Narrow hides keyboard
   and progress bar entirely. Short terminals (<20 rows) also hide
   keyboard/progress.

5. Delete sessions from history: Add j/k row navigation in history
   tab, x/Delete to initiate deletion with y/n confirmation dialog.
   Full chronological replay rebuilds key_stats, letter_unlock,
   profile scoring, and streak tracking. Only adaptive sessions update
   key_stats/letter_unlock during rebuild. LessonResult now persists
   lesson_mode for correct replay gating.

6. Improved statistics display: Bordered summary table on dashboard,
   WPM bar graph using block characters (green above goal, red below),
   accuracy Braille trend chart, bordered history table with WPM goal
   indicators and selected-row highlighting, character speed
   distribution with time labels, keyboard accuracy heatmap with
   percentage text per key, worst accuracy keys panel, new 7-month
   activity calendar heatmap widget with theme-derived intensity
   colors, side-by-side panel layout for terminals >170 cols wide.

Also: ignore KeyEventKind::Repeat for typing input, clamp history
selection to visible 20-row range, and suppress dead_code warnings
on now-unused WpmChart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 00:20:25 +00:00
parent c78a8a90a3
commit a0e8f3cafb
13 changed files with 1385 additions and 271 deletions

View File

@@ -0,0 +1,152 @@
use std::collections::HashMap;
use chrono::{Datelike, NaiveDate, Utc};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Widget};
use crate::session::result::LessonResult;
use crate::ui::theme::Theme;
pub struct ActivityHeatmap<'a> {
history: &'a [LessonResult],
theme: &'a Theme,
}
impl<'a> ActivityHeatmap<'a> {
pub fn new(history: &'a [LessonResult], theme: &'a Theme) -> Self {
Self { history, theme }
}
}
impl Widget for ActivityHeatmap<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Activity ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 9 || inner.width < 30 {
return;
}
// Count sessions per day
let mut day_counts: HashMap<NaiveDate, usize> = HashMap::new();
for result in self.history {
let date = result.timestamp.date_naive();
*day_counts.entry(date).or_insert(0) += 1;
}
let today = Utc::now().date_naive();
// 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);
// Align to Monday
let start_date = start_date
- chrono::Duration::days(start_date.weekday().num_days_from_monday() as i64);
// Day-of-week labels
let day_labels = ["M", " ", "W", " ", "F", " ", "S"];
for (row, label) in day_labels.iter().enumerate() {
let y = inner.y + 1 + row as u16;
if y < inner.y + inner.height {
buf.set_string(
inner.x,
y,
label,
Style::default().fg(colors.text_pending()),
);
}
}
// Render weeks as columns
let mut current_date = start_date;
let mut col = 0u16;
// Month labels
let mut last_month = 0u32;
while current_date <= today {
let x = inner.x + 2 + col * 2;
if x + 1 >= inner.x + inner.width {
break;
}
// Month label on first row
let month = current_date.month();
if month != last_month {
let month_name = match month {
1 => "Jan",
2 => "Feb",
3 => "Mar",
4 => "Apr",
5 => "May",
6 => "Jun",
7 => "Jul",
8 => "Aug",
9 => "Sep",
10 => "Oct",
11 => "Nov",
12 => "Dec",
_ => "",
};
// Only show if we have space (3 chars)
if x + 3 <= inner.x + inner.width {
buf.set_string(
x,
inner.y,
month_name,
Style::default().fg(colors.text_pending()),
);
}
last_month = month;
}
// 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 {
break;
}
let y = inner.y + 1 + day_offset;
if y >= inner.y + inner.height {
break;
}
let count = day_counts.get(&date).copied().unwrap_or(0);
let (ch, color) = intensity_cell(count, colors);
buf.set_string(x, y, &ch.to_string(), Style::default().fg(color));
}
current_date += chrono::Duration::weeks(1);
col += 1;
}
}
}
fn scale_color(base: Color, factor: f64) -> Color {
match base {
Color::Rgb(r, g, b) => Color::Rgb(
(r as f64 * factor).min(255.0) as u8,
(g as f64 * factor).min(255.0) as u8,
(b as f64 * factor).min(255.0) as u8,
),
other => other,
}
}
fn intensity_cell(count: usize, colors: &crate::ui::theme::ThemeColors) -> (char, Color) {
let success = colors.success();
match count {
0 => ('·', colors.accent_dim()),
1..=2 => ('▪', scale_color(success, 0.4)),
3..=5 => ('▪', scale_color(success, 0.65)),
6..=15 => ('█', scale_color(success, 0.85)),
_ => ('█', success),
}
}

View File

@@ -6,11 +6,13 @@ use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Widget};
use crate::ui::theme::Theme;
#[allow(dead_code)]
pub struct WpmChart<'a> {
pub data: &'a [(f64, f64)],
pub theme: &'a Theme,
}
#[allow(dead_code)]
impl<'a> WpmChart<'a> {
pub fn new(data: &'a [(f64, f64)], theme: &'a Theme) -> Self {
Self { data, theme }

View File

@@ -1,6 +1,8 @@
use std::collections::HashSet;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Widget};
use crate::keyboard::finger::{self, Finger, Hand};
@@ -10,7 +12,9 @@ pub struct KeyboardDiagram<'a> {
pub focused_key: Option<char>,
pub next_key: Option<char>,
pub unlocked_keys: &'a [char],
pub depressed_keys: &'a HashSet<char>,
pub theme: &'a Theme,
pub compact: bool,
}
impl<'a> KeyboardDiagram<'a> {
@@ -18,15 +22,23 @@ impl<'a> KeyboardDiagram<'a> {
focused_key: Option<char>,
next_key: Option<char>,
unlocked_keys: &'a [char],
depressed_keys: &'a HashSet<char>,
theme: &'a Theme,
) -> Self {
Self {
focused_key,
next_key,
unlocked_keys,
depressed_keys,
theme,
compact: false,
}
}
pub fn compact(mut self, compact: bool) -> Self {
self.compact = compact;
self
}
}
const ROWS: &[&[char]] = &[
@@ -50,6 +62,17 @@ fn finger_color(ch: char) -> Color {
}
}
fn brighten_color(color: Color) -> Color {
match color {
Color::Rgb(r, g, b) => Color::Rgb(
r.saturating_add(60),
g.saturating_add(60),
b.saturating_add(60),
),
other => other,
}
}
impl Widget for KeyboardDiagram<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
@@ -61,12 +84,18 @@ impl Widget for KeyboardDiagram<'_> {
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 3 || inner.width < 30 {
let key_width: u16 = if self.compact { 3 } else { 5 };
let min_width: u16 = if self.compact { 21 } else { 30 };
if inner.height < 3 || inner.width < min_width {
return;
}
let key_width: u16 = 5;
let offsets: &[u16] = &[1, 3, 5];
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;
@@ -82,11 +111,23 @@ impl Widget for KeyboardDiagram<'_> {
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 style = if is_next {
// Priority: depressed > next_expected > focused > unlocked > locked
let style = if is_depressed {
let bg = if is_unlocked {
brighten_color(finger_color(key))
} 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())
@@ -104,7 +145,11 @@ impl Widget for KeyboardDiagram<'_> {
.bg(colors.bg())
};
let display = format!("[ {key} ]");
let display = if self.compact {
format!("[{key}]")
} else {
format!("[ {key} ]")
};
buf.set_string(x, y, &display, style);
}
}

View File

@@ -1,3 +1,4 @@
pub mod activity_heatmap;
pub mod chart;
pub mod dashboard;
pub mod keyboard_diagram;

View File

@@ -6,7 +6,7 @@ use ratatui::widgets::{Block, Paragraph, Widget};
use crate::engine::key_stats::KeyStatsStore;
use crate::session::result::LessonResult;
use crate::ui::components::chart::WpmChart;
use crate::ui::components::activity_heatmap::ActivityHeatmap;
use crate::ui::theme::Theme;
pub struct StatsDashboard<'a> {
@@ -15,6 +15,8 @@ pub struct StatsDashboard<'a> {
pub active_tab: usize,
pub target_wpm: u32,
pub theme: &'a Theme,
pub history_selected: usize,
pub history_confirm_delete: bool,
}
impl<'a> StatsDashboard<'a> {
@@ -24,6 +26,8 @@ impl<'a> StatsDashboard<'a> {
active_tab: usize,
target_wpm: u32,
theme: &'a Theme,
history_selected: usize,
history_confirm_delete: bool,
) -> Self {
Self {
history,
@@ -31,6 +35,8 @@ impl<'a> StatsDashboard<'a> {
active_tab,
target_wpm,
theme,
history_selected,
history_confirm_delete,
}
}
}
@@ -85,37 +91,88 @@ impl Widget for StatsDashboard<'_> {
.collect();
Paragraph::new(Line::from(tab_spans)).render(layout[0], buf);
// Tab content
match self.active_tab {
0 => self.render_dashboard_tab(layout[1], buf),
1 => self.render_history_tab(layout[1], buf),
2 => self.render_keystrokes_tab(layout[1], 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);
}
// Footer
let footer_text = if self.active_tab == 1 {
" [ESC] Back [Tab] Next tab [j/k] Navigate [x] Delete"
} else {
" [ESC] Back [Tab] Next tab [D/H/K] Switch tab"
};
let footer = Paragraph::new(Line::from(Span::styled(
" [ESC] Back [Tab] Next tab [D/H/K] Switch tab",
footer_text,
Style::default().fg(colors.accent()),
)));
footer.render(layout[2], buf);
// Confirmation dialog overlay
if self.history_confirm_delete && self.active_tab == 1 {
let dialog_width = 34u16;
let dialog_height = 5u16;
let dialog_x = area.x + area.width.saturating_sub(dialog_width) / 2;
let dialog_y = area.y + area.height.saturating_sub(dialog_height) / 2;
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
let idx = self.history.len().saturating_sub(self.history_selected);
let dialog_text = format!("Delete session #{idx}? (y/n)");
let dialog = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
format!(" {dialog_text} "),
Style::default().fg(colors.fg()),
)),
])
.block(
Block::bordered()
.title(" Confirm ")
.border_style(Style::default().fg(colors.error()))
.style(Style::default().bg(colors.bg())),
);
dialog.render(dialog_area, buf);
}
}
}
impl StatsDashboard<'_> {
fn render_tab(&self, tab: usize, area: Rect, buf: &mut Buffer) {
match tab {
0 => self.render_dashboard_tab(area, buf),
1 => self.render_history_tab(area, buf),
2 => self.render_keystrokes_tab(area, buf),
_ => {}
}
}
fn render_dashboard_tab(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4),
Constraint::Length(3),
Constraint::Min(8),
Constraint::Length(6), // summary stats bordered box
Constraint::Length(3), // progress bars
Constraint::Min(8), // charts
])
.split(area);
// Summary stats
// Summary stats as bordered table
let avg_wpm =
self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
let best_wpm = self
@@ -133,19 +190,29 @@ impl StatsDashboard<'_> {
let avg_acc_str = format!("{avg_accuracy:.1}%");
let time_str = format_duration(total_time);
let summary_block = Block::bordered()
.title(" Summary ")
.border_style(Style::default().fg(colors.border()));
let summary_inner = summary_block.inner(layout[0]);
summary_block.render(layout[0], buf);
let summary = vec![
Line::from(vec![
Span::styled(" Lessons: ", Style::default().fg(colors.fg())),
Span::styled(
&*total_str,
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
),
Span::styled(" Avg WPM: ", Style::default().fg(colors.fg())),
Span::styled(&*avg_wpm_str, Style::default().fg(colors.accent())),
Span::styled(" Best WPM: ", Style::default().fg(colors.fg())),
Span::styled(
&*best_wpm_str,
Style::default().fg(colors.success()).add_modifier(Modifier::BOLD),
Style::default()
.fg(colors.success())
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
@@ -164,30 +231,108 @@ impl StatsDashboard<'_> {
Span::styled(&*time_str, Style::default().fg(colors.text_pending())),
]),
];
Paragraph::new(summary).render(layout[0], buf);
Paragraph::new(summary).render(summary_inner, buf);
// Progress bars
self.render_progress_bars(layout[1], buf);
// Charts
// Charts: WPM bar graph + accuracy trend
let chart_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(layout[2]);
// WPM chart
let wpm_data: Vec<(f64, f64)> = self
self.render_wpm_bar_graph(chart_layout[0], buf);
self.render_accuracy_chart(chart_layout[1], buf);
}
fn render_wpm_bar_graph(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" WPM (Last 20) ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
if inner.width < 10 || inner.height < 3 {
return;
}
let recent: Vec<f64> = self
.history
.iter()
.rev()
.take(50)
.enumerate()
.map(|(i, r)| (i as f64, r.wpm))
.take(20)
.map(|r| r.wpm)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
WpmChart::new(&wpm_data, self.theme).render(chart_layout[0], buf);
// Accuracy chart
let acc_data: Vec<(f64, f64)> = self
if recent.is_empty() {
return;
}
let max_wpm = recent.iter().fold(0.0f64, |a, &b| a.max(b)).max(10.0);
let target = self.target_wpm as f64;
let bar_count = (inner.width as usize).min(recent.len());
let bar_spacing = if bar_count > 0 {
inner.width / bar_count as u16
} else {
return;
};
let bar_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
// Render each bar as a column
let start_idx = recent.len().saturating_sub(bar_count);
for (i, &wpm) in recent[start_idx..].iter().enumerate() {
let x = inner.x + i as u16 * bar_spacing;
if x >= inner.x + inner.width {
break;
}
let ratio = (wpm / max_wpm).clamp(0.0, 1.0);
let bar_height = (ratio * (inner.height as f64 - 1.0)).round() as usize;
let color = if wpm >= target {
colors.success()
} else {
colors.error()
};
// Draw bar from bottom up
for row in 0..inner.height.saturating_sub(1) {
let y = inner.y + inner.height - 1 - row;
let row_idx = row as usize;
if row_idx < bar_height {
let ch = if row_idx + 1 == bar_height {
// Top of bar - use fractional char
let frac = (ratio * (inner.height as f64 - 1.0)) - bar_height as f64 + 1.0;
let idx = ((frac * 7.0).round() as usize).min(7);
bar_chars[idx]
} else {
'█'
};
buf.set_string(x, y, &ch.to_string(), Style::default().fg(color));
}
}
// WPM label on top row
if bar_spacing >= 3 {
let label = format!("{wpm:.0}");
buf.set_string(x, inner.y, &label, Style::default().fg(colors.text_pending()));
}
}
}
fn render_accuracy_chart(&self, area: Rect, buf: &mut Buffer) {
use ratatui::symbols;
use ratatui::widgets::{Axis, Chart, Dataset, GraphType};
let colors = &self.theme.colors;
let data: Vec<(f64, f64)> = self
.history
.iter()
.rev()
@@ -195,7 +340,43 @@ impl StatsDashboard<'_> {
.enumerate()
.map(|(i, r)| (i as f64, r.accuracy))
.collect();
render_accuracy_chart(&acc_data, self.theme, chart_layout[1], buf);
if data.is_empty() {
let block = Block::bordered()
.title(" Accuracy Trend ")
.border_style(Style::default().fg(colors.border()));
block.render(area, buf);
return;
}
let max_x = data.last().map(|(x, _)| *x).unwrap_or(1.0);
let dataset = Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(colors.success()))
.data(&data);
let chart = Chart::new(vec![dataset])
.block(
Block::bordered()
.title(" Accuracy Trend ")
.border_style(Style::default().fg(colors.border())),
)
.x_axis(
Axis::default()
.title("Lesson")
.style(Style::default().fg(colors.text_pending()))
.bounds([0.0, max_x]),
)
.y_axis(
Axis::default()
.title("%")
.style(Style::default().fg(colors.text_pending()))
.bounds([80.0, 100.0]),
);
chart.render(area, buf);
}
fn render_progress_bars(&self, area: Rect, buf: &mut Buffer) {
@@ -217,8 +398,20 @@ impl StatsDashboard<'_> {
// WPM progress
let wpm_pct = (avg_wpm / self.target_wpm as f64 * 100.0).min(100.0);
let wpm_color = if wpm_pct >= 100.0 {
colors.success()
} else {
colors.accent()
};
let wpm_label = format!(" WPM: {avg_wpm:.0}/{} ({wpm_pct:.0}%)", self.target_wpm);
render_text_bar(&wpm_label, wpm_pct / 100.0, colors.accent(), colors.bar_empty(), layout[0], buf);
render_text_bar(
&wpm_label,
wpm_pct / 100.0,
wpm_color,
colors.bar_empty(),
layout[0],
buf,
);
// Accuracy progress
let acc_pct = avg_accuracy.min(100.0);
@@ -230,16 +423,32 @@ impl StatsDashboard<'_> {
} else {
colors.error()
};
render_text_bar(&acc_label, acc_pct / 100.0, acc_color, colors.bar_empty(), layout[1], buf);
render_text_bar(
&acc_label,
acc_pct / 100.0,
acc_color,
colors.bar_empty(),
layout[1],
buf,
);
// Level progress
let total_score: f64 = self.history.iter().map(|r| r.wpm * r.accuracy / 100.0).sum();
let level = ((total_score / 100.0).sqrt() as u32).max(1);
let next_level_score = ((level + 1) as f64).powi(2) * 100.0;
let current_level_score = (level as f64).powi(2) * 100.0;
let level_pct = ((total_score - current_level_score) / (next_level_score - current_level_score)).clamp(0.0, 1.0);
let level_pct = ((total_score - current_level_score)
/ (next_level_score - current_level_score))
.clamp(0.0, 1.0);
let level_label = format!(" Lvl {level} ({:.0}%)", level_pct * 100.0);
render_text_bar(&level_label, level_pct, colors.focused_key(), colors.bar_empty(), layout[2], buf);
render_text_bar(
&level_label,
level_pct,
colors.focused_key(),
colors.bar_empty(),
layout[2],
buf,
);
}
fn render_history_tab(&self, area: Rect, buf: &mut Buffer) {
@@ -247,26 +456,30 @@ impl StatsDashboard<'_> {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(10),
Constraint::Length(8),
])
.constraints([Constraint::Min(10), Constraint::Length(8)])
.split(area);
// Recent tests table
let header = Line::from(vec![
Span::styled(
" # WPM Raw Acc% Time Date",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
),
]);
// 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);
let mut lines = vec![header, Line::from(Span::styled(
" ─────────────────────────────────────────────",
Style::default().fg(colors.border()),
))];
let header = Line::from(vec![Span::styled(
" # WPM Raw Acc% Time Date",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)]);
let mut lines = vec![
header,
Line::from(Span::styled(
" ─────────────────────────────────────────────",
Style::default().fg(colors.border()),
)),
];
let recent: Vec<&LessonResult> = self.history.iter().rev().take(20).collect();
let total = self.history.len();
@@ -281,7 +494,17 @@ impl StatsDashboard<'_> {
let wpm_str = format!("{:>6.0}", result.wpm);
let raw_str = format!("{:>6.0}", raw_wpm);
let acc_str = format!("{:>6.1}%", result.accuracy);
let row = format!(" {idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str}");
// WPM indicator
let wpm_indicator = if result.wpm >= self.target_wpm as f64 {
"+"
} else {
" "
};
let row = format!(
" {wpm_indicator}{idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str}"
);
let acc_color = if result.accuracy >= 95.0 {
colors.success()
@@ -291,12 +514,19 @@ impl StatsDashboard<'_> {
colors.error()
};
lines.push(Line::from(Span::styled(row, Style::default().fg(acc_color))));
let is_selected = i == self.history_selected;
let style = if is_selected {
Style::default().fg(acc_color).bg(colors.accent_dim())
} else {
Style::default().fg(acc_color)
};
lines.push(Line::from(Span::styled(row, style)));
}
Paragraph::new(lines).render(layout[0], buf);
Paragraph::new(lines).render(table_inner, buf);
// Per-key speed
// Per-key speed distribution
self.render_per_key_speed(layout[1], buf);
}
@@ -304,7 +534,7 @@ impl StatsDashboard<'_> {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Per-Key Average Speed (ms) ")
.title(" Character Speed Distribution ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
@@ -321,9 +551,7 @@ impl StatsDashboard<'_> {
.fold(0.0f64, f64::max)
.max(1.0);
// Render bar chart: letter label on row 0, bar on row 1
let bar_width = (inner.width as usize).min(52) / 26;
let bar_width = bar_width.max(1) as u16;
let bar_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
for (i, &ch) in letters.iter().enumerate() {
let x = inner.x + (i as u16 * 2).min(inner.width.saturating_sub(1));
@@ -350,19 +578,11 @@ impl StatsDashboard<'_> {
// Letter label
buf.set_string(x, inner.y, &ch.to_string(), Style::default().fg(color));
// Simple bar indicator
// Bar indicator
if inner.height >= 2 {
let bar_char = if time > 0.0 {
match (ratio * 8.0) as u8 {
0 => '▁',
1 => '▂',
2 => '▃',
3 => '▄',
4 => '▅',
5 => '▆',
6 => '▇',
_ => '█',
}
let idx = ((ratio * 7.0).round() as usize).min(7);
bar_chars[idx]
} else {
' '
};
@@ -373,25 +593,41 @@ impl StatsDashboard<'_> {
Style::default().fg(color),
);
}
}
let _ = bar_width;
// Time label on row 3
if inner.height >= 3 && time > 0.0 {
let time_label = format!("{time:.0}");
if x + time_label.len() as u16 <= inner.x + inner.width {
buf.set_string(
x,
inner.y + 2,
&time_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(7),
Constraint::Min(5),
Constraint::Length(6),
Constraint::Length(12), // Activity heatmap
Constraint::Length(7), // Keyboard accuracy heatmap
Constraint::Min(5), // Slowest/Fastest/Stats
Constraint::Length(5), // Overall stats
])
.split(area);
// Keyboard accuracy heatmap
self.render_keyboard_heatmap(layout[0], buf);
// Activity heatmap
let heatmap = ActivityHeatmap::new(self.history, self.theme);
heatmap.render(layout[0], buf);
// Slowest/Fastest keys
// 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([
@@ -399,14 +635,14 @@ impl StatsDashboard<'_> {
Constraint::Percentage(34),
Constraint::Percentage(33),
])
.split(layout[1]);
.split(layout[2]);
self.render_slowest_keys(key_layout[0], buf);
self.render_fastest_keys(key_layout[1], buf);
self.render_char_stats(key_layout[2], buf);
self.render_worst_accuracy_keys(key_layout[2], buf);
// Word/Character stats summary
self.render_overall_stats(layout[2], buf);
// Overall stats
self.render_overall_stats(layout[3], buf);
}
fn render_keyboard_heatmap(&self, area: Rect, buf: &mut Buffer) {
@@ -418,7 +654,7 @@ impl StatsDashboard<'_> {
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 3 || inner.width < 40 {
if inner.height < 3 || inner.width < 50 {
return;
}
@@ -428,7 +664,7 @@ impl StatsDashboard<'_> {
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
];
let offsets: &[u16] = &[1, 3, 5];
let key_width: u16 = 4;
let key_width: u16 = 5; // wider to fit accuracy %
for (row_idx, row) in rows.iter().enumerate() {
let y = inner.y + row_idx as u16;
@@ -440,23 +676,33 @@ impl StatsDashboard<'_> {
for (col_idx, &key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_width;
if x + 3 > inner.x + inner.width {
if x + key_width > inner.x + inner.width {
break;
}
let accuracy = self.get_key_accuracy(key);
let color = if accuracy >= 100.0 {
colors.text_pending()
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()
} else if accuracy > 0.0 {
colors.error()
(colors.warning(), colors.bg())
} else {
colors.text_pending()
(colors.error(), colors.bg())
};
let display = format!("[{key}]");
buf.set_string(x, y, &display, Style::default().fg(color).bg(colors.bg()));
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),
);
}
}
}
@@ -548,43 +794,63 @@ impl StatsDashboard<'_> {
}
}
fn render_char_stats(&self, area: Rect, buf: &mut Buffer) {
fn render_worst_accuracy_keys(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Key Stats ")
.title(" Worst Accuracy ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
let mut total_correct = 0usize;
let mut total_incorrect = 0usize;
// Compute accuracy for each key
let mut key_accuracies: Vec<(char, f64, usize)> = ('a'..='z')
.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))
} else {
None
}
})
.collect();
for result in self.history {
total_correct += result.correct;
total_incorrect += result.incorrect;
key_accuracies.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
if key_accuracies.is_empty() {
buf.set_string(
inner.x,
inner.y,
" Not enough data",
Style::default().fg(colors.text_pending()),
);
return;
}
let total = total_correct + total_incorrect;
let overall_acc = if total > 0 {
total_correct as f64 / total as f64 * 100.0
} else {
0.0
};
let lines = [
format!(" Total: {total}"),
format!(" Correct: {total_correct}"),
format!(" Wrong: {total_incorrect}"),
format!(" Acc: {overall_acc:.1}%"),
];
for (i, line) in lines.iter().enumerate() {
for (i, (ch, acc, _total)) in key_accuracies.iter().take(5).enumerate() {
let y = inner.y + i as u16;
if y >= inner.y + inner.height {
break;
}
buf.set_string(inner.x, y, line, Style::default().fg(colors.fg()));
let badge = format!(" '{ch}' {acc:.1}%");
let color = if *acc >= 95.0 {
colors.warning()
} else {
colors.error()
};
buf.set_string(inner.x, y, &badge, Style::default().fg(color));
}
}
@@ -602,34 +868,32 @@ impl StatsDashboard<'_> {
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 lines = vec![
Line::from(vec![
Span::styled(" Characters typed: ", Style::default().fg(colors.fg())),
Span::styled(
format!("{total_chars}"),
Style::default().fg(colors.accent()),
),
Span::styled(" Correct: ", Style::default().fg(colors.fg())),
Span::styled(
format!("{total_correct}"),
Style::default().fg(colors.success()),
),
Span::styled(" Errors: ", Style::default().fg(colors.fg())),
Span::styled(
format!("{total_incorrect}"),
Style::default().fg(if total_incorrect > 0 {
colors.error()
} else {
colors.success()
}),
),
Span::styled(" Total time: ", Style::default().fg(colors.fg())),
Span::styled(
format_duration(total_time),
Style::default().fg(colors.text_pending()),
),
]),
];
let lines = vec![Line::from(vec![
Span::styled(" Characters: ", Style::default().fg(colors.fg())),
Span::styled(
format!("{total_chars}"),
Style::default().fg(colors.accent()),
),
Span::styled(" Correct: ", Style::default().fg(colors.fg())),
Span::styled(
format!("{total_correct}"),
Style::default().fg(colors.success()),
),
Span::styled(" Errors: ", 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),
Style::default().fg(colors.text_pending()),
),
])];
Paragraph::new(lines).render(inner, buf);
}
@@ -655,7 +919,7 @@ fn render_text_bar(
Style::default().fg(fill_color),
);
// Bar on second line
// Bar on second line using ┃ filled / dim ┃ empty
let bar_width = (area.width as usize).saturating_sub(4);
let filled = (ratio * bar_width as f64) as usize;
@@ -676,55 +940,6 @@ fn render_text_bar(
}
}
fn render_accuracy_chart(
data: &[(f64, f64)],
theme: &Theme,
area: Rect,
buf: &mut Buffer,
) {
use ratatui::symbols;
use ratatui::widgets::{Axis, Chart, Dataset, GraphType};
let colors = &theme.colors;
if data.is_empty() {
let block = Block::bordered()
.title(" Accuracy Over Time ")
.border_style(Style::default().fg(colors.border()));
block.render(area, buf);
return;
}
let max_x = data.last().map(|(x, _)| *x).unwrap_or(1.0);
let dataset = Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(colors.success()))
.data(data);
let chart = Chart::new(vec![dataset])
.block(
Block::bordered()
.title(" Accuracy Over Time ")
.border_style(Style::default().fg(colors.border())),
)
.x_axis(
Axis::default()
.title("Lesson")
.style(Style::default().fg(colors.text_pending()))
.bounds([0.0, max_x]),
)
.y_axis(
Axis::default()
.title("%")
.style(Style::default().fg(colors.text_pending()))
.bounds([80.0, 100.0]),
);
chart.render(area, buf);
}
fn format_duration(secs: f64) -> String {
let total = secs as u64;
let hours = total / 3600;

View File

@@ -26,7 +26,7 @@ impl Widget for StatsSidebar<'_> {
let accuracy = self.lesson.accuracy();
let progress = self.lesson.progress() * 100.0;
let correct = self.lesson.correct_count();
let incorrect = self.lesson.incorrect_count();
let incorrect = self.lesson.typo_count();
let elapsed = self.lesson.elapsed_secs();
let wpm_str = format!("{wpm:.0}");