2062 lines
71 KiB
Rust
2062 lines
71 KiB
Rust
use chrono::{Datelike, Utc};
|
||
use ratatui::buffer::Buffer;
|
||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||
use ratatui::style::{Modifier, Style};
|
||
use ratatui::text::{Line, Span};
|
||
use ratatui::widgets::{Block, Clear, Paragraph, Widget};
|
||
use std::collections::{BTreeSet, HashMap};
|
||
|
||
use crate::engine::key_stats::KeyStatsStore;
|
||
use crate::engine::ngram_stats::{AnomalyType, FocusSelection};
|
||
use crate::keyboard::display::{self, BACKSPACE, ENTER, MODIFIER_SENTINELS, SPACE, TAB};
|
||
use crate::keyboard::model::KeyboardModel;
|
||
use crate::session::result::DrillResult;
|
||
use crate::ui::components::activity_heatmap::ActivityHeatmap;
|
||
use crate::ui::layout::pack_hint_lines;
|
||
use crate::ui::theme::Theme;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// N-grams tab view models
|
||
// ---------------------------------------------------------------------------
|
||
|
||
pub struct AnomalyBigramRow {
|
||
pub bigram: String,
|
||
pub anomaly_pct: f64,
|
||
pub sample_count: usize,
|
||
pub error_count: usize,
|
||
pub error_rate_ema: f64,
|
||
pub speed_ms: f64,
|
||
pub expected_baseline: f64,
|
||
pub confirmed: bool,
|
||
}
|
||
|
||
pub struct NgramTabData {
|
||
pub focus: FocusSelection,
|
||
pub error_anomalies: Vec<AnomalyBigramRow>,
|
||
pub speed_anomalies: Vec<AnomalyBigramRow>,
|
||
pub total_bigrams: usize,
|
||
pub total_trigrams: usize,
|
||
pub hesitation_threshold_ms: f64,
|
||
pub latest_trigram_gain: Option<f64>,
|
||
pub scope_label: String,
|
||
}
|
||
|
||
pub struct StatsDashboard<'a> {
|
||
pub history: &'a [DrillResult],
|
||
pub key_stats: &'a KeyStatsStore,
|
||
pub active_tab: usize,
|
||
pub target_wpm: u32,
|
||
pub overall_unlocked: usize,
|
||
pub overall_mastered: usize,
|
||
pub overall_total: usize,
|
||
pub theme: &'a Theme,
|
||
pub history_selected: usize,
|
||
pub history_scroll: usize,
|
||
pub history_confirm_delete: bool,
|
||
pub keyboard_model: &'a KeyboardModel,
|
||
pub ngram_data: Option<&'a NgramTabData>,
|
||
}
|
||
|
||
impl<'a> StatsDashboard<'a> {
|
||
pub fn new(
|
||
history: &'a [DrillResult],
|
||
key_stats: &'a KeyStatsStore,
|
||
active_tab: usize,
|
||
target_wpm: u32,
|
||
overall_unlocked: usize,
|
||
overall_mastered: usize,
|
||
overall_total: usize,
|
||
theme: &'a Theme,
|
||
history_selected: usize,
|
||
history_scroll: usize,
|
||
history_confirm_delete: bool,
|
||
keyboard_model: &'a KeyboardModel,
|
||
ngram_data: Option<&'a NgramTabData>,
|
||
) -> Self {
|
||
Self {
|
||
history,
|
||
key_stats,
|
||
active_tab,
|
||
target_wpm,
|
||
overall_unlocked,
|
||
overall_mastered,
|
||
overall_total,
|
||
theme,
|
||
history_selected,
|
||
history_scroll,
|
||
history_confirm_delete,
|
||
keyboard_model,
|
||
ngram_data,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Widget for StatsDashboard<'_> {
|
||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||
let colors = &self.theme.colors;
|
||
|
||
let block = Block::bordered()
|
||
.title(" Statistics ")
|
||
.border_style(Style::default().fg(colors.accent()))
|
||
.style(Style::default().bg(colors.bg()));
|
||
let inner = block.inner(area);
|
||
block.render(area, buf);
|
||
|
||
if self.history.is_empty() {
|
||
let msg = Paragraph::new(Line::from(Span::styled(
|
||
"No drills completed yet. Start typing!",
|
||
Style::default().fg(colors.text_pending()),
|
||
)));
|
||
msg.render(inner, buf);
|
||
return;
|
||
}
|
||
|
||
// Tab header — width-aware wrapping
|
||
let width = inner.width as usize;
|
||
let mut tab_lines: Vec<Line> = Vec::new();
|
||
let mut current_spans: Vec<Span> = Vec::new();
|
||
let mut current_width: usize = 0;
|
||
for (i, &label) in TAB_LABELS.iter().enumerate() {
|
||
let styled_label = format!(" {label} ");
|
||
let item_width = styled_label.chars().count() + TAB_SEPARATOR.len();
|
||
if current_width > 0 && current_width + item_width > width {
|
||
tab_lines.push(Line::from(current_spans));
|
||
current_spans = Vec::new();
|
||
current_width = 0;
|
||
}
|
||
let style = if i == self.active_tab {
|
||
Style::default()
|
||
.fg(colors.accent())
|
||
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
|
||
} else {
|
||
Style::default().fg(colors.text_pending())
|
||
};
|
||
current_spans.push(Span::styled(styled_label, style));
|
||
current_spans.push(Span::raw(TAB_SEPARATOR));
|
||
current_width += item_width;
|
||
}
|
||
if !current_spans.is_empty() {
|
||
tab_lines.push(Line::from(current_spans));
|
||
}
|
||
let tab_line_count = tab_lines.len().max(1) as u16;
|
||
|
||
// Footer — width-aware wrapping
|
||
let footer_hints: Vec<&str> = if self.active_tab == 1 {
|
||
FOOTER_HINTS_HISTORY.to_vec()
|
||
} else {
|
||
FOOTER_HINTS_DEFAULT.to_vec()
|
||
};
|
||
let footer_lines_vec = pack_hint_lines(&footer_hints, width);
|
||
let footer_line_count = footer_lines_vec.len().max(1) as u16;
|
||
|
||
let layout = Layout::default()
|
||
.direction(Direction::Vertical)
|
||
.constraints([
|
||
Constraint::Length(tab_line_count),
|
||
Constraint::Min(10),
|
||
Constraint::Length(footer_line_count),
|
||
])
|
||
.split(inner);
|
||
|
||
Paragraph::new(tab_lines).render(layout[0], 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_lines: Vec<Line> = footer_lines_vec
|
||
.into_iter()
|
||
.map(|line| Line::from(Span::styled(line, Style::default().fg(colors.accent()))))
|
||
.collect();
|
||
Paragraph::new(footer_lines).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)");
|
||
|
||
Clear.render(dialog_area, buf);
|
||
let dialog = Paragraph::new(vec![
|
||
Line::from(""),
|
||
Line::from(Span::styled(
|
||
format!(" {dialog_text} "),
|
||
Style::default().fg(colors.fg()),
|
||
)),
|
||
])
|
||
.style(Style::default().bg(colors.bg()))
|
||
.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_activity_tab(area, buf),
|
||
3 => self.render_accuracy_tab(area, buf),
|
||
4 => self.render_timing_tab(area, buf),
|
||
5 => self.render_ngram_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(6)])
|
||
.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) {
|
||
// Give keyboard as much height as available (up to 12), reserving 6 for lists below
|
||
let kbd_height: u16 = area.height.saturating_sub(6).min(12).max(7);
|
||
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) {
|
||
// Give keyboard as much height as available (up to 12), reserving 6 for lists below
|
||
let kbd_height: u16 = area.height.saturating_sub(6).min(12).max(7);
|
||
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;
|
||
|
||
let layout = Layout::default()
|
||
.direction(Direction::Vertical)
|
||
.constraints([
|
||
Constraint::Length(6), // summary stats bordered box
|
||
Constraint::Length(3), // progress bars
|
||
Constraint::Min(8), // charts
|
||
])
|
||
.split(area);
|
||
|
||
// 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.history.iter().map(|r| r.wpm).fold(0.0f64, f64::max);
|
||
let avg_accuracy =
|
||
self.history.iter().map(|r| r.accuracy).sum::<f64>() / self.history.len() as f64;
|
||
let total_time: f64 = self.history.iter().map(|r| r.elapsed_secs).sum();
|
||
|
||
let total_str = format!("{}", self.history.len());
|
||
let avg_wpm_str = format!("{avg_wpm:.0}");
|
||
let best_wpm_str = format!("{best_wpm:.0}");
|
||
let avg_acc_str = format!("{avg_accuracy:.1}%");
|
||
let time_str = format_duration(total_time);
|
||
|
||
let summary_block = Block::bordered()
|
||
.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);
|
||
|
||
let summary = vec![
|
||
Line::from(vec![
|
||
Span::styled(" Drills: ", Style::default().fg(colors.fg())),
|
||
Span::styled(
|
||
&*total_str,
|
||
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),
|
||
),
|
||
]),
|
||
Line::from(vec![
|
||
Span::styled(" Accuracy: ", Style::default().fg(colors.fg())),
|
||
Span::styled(
|
||
&*avg_acc_str,
|
||
Style::default().fg(if avg_accuracy >= 95.0 {
|
||
colors.success()
|
||
} else if avg_accuracy >= 85.0 {
|
||
colors.warning()
|
||
} else {
|
||
colors.error()
|
||
}),
|
||
),
|
||
Span::styled(" Total time: ", Style::default().fg(colors.fg())),
|
||
Span::styled(&*time_str, Style::default().fg(colors.text_pending())),
|
||
]),
|
||
];
|
||
Paragraph::new(summary).render(summary_inner, buf);
|
||
|
||
// Progress bars
|
||
self.render_progress_bars(layout[1], buf);
|
||
|
||
// Charts: WPM bar graph + accuracy trend
|
||
let chart_layout = Layout::default()
|
||
.direction(Direction::Horizontal)
|
||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||
.split(layout[2]);
|
||
|
||
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 target_label = format!(" WPM per Drill (Last 20, Target: {}) ", self.target_wpm);
|
||
let block = Block::bordered()
|
||
.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);
|
||
|
||
if inner.width < 10 || inner.height < 3 {
|
||
return;
|
||
}
|
||
|
||
let recent: Vec<f64> = self
|
||
.history
|
||
.iter()
|
||
.rev()
|
||
.take(20)
|
||
.map(|r| r.wpm)
|
||
.collect::<Vec<_>>()
|
||
.into_iter()
|
||
.rev()
|
||
.collect();
|
||
|
||
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;
|
||
|
||
// Reserve left margin for Y-axis labels
|
||
let y_label_width: u16 = 4;
|
||
let chart_x = inner.x + y_label_width;
|
||
let chart_width = inner.width.saturating_sub(y_label_width);
|
||
|
||
if chart_width < 5 {
|
||
return;
|
||
}
|
||
|
||
let bar_count = (chart_width as usize).min(recent.len());
|
||
let bar_spacing = if bar_count > 0 {
|
||
chart_width / bar_count as u16
|
||
} else {
|
||
return;
|
||
};
|
||
|
||
// Y-axis labels (max, mid, 0)
|
||
let max_label = format!("{:.0}", max_wpm);
|
||
let mid_label = format!("{:.0}", max_wpm / 2.0);
|
||
buf.set_string(
|
||
inner.x,
|
||
inner.y,
|
||
&max_label,
|
||
Style::default().fg(colors.text_pending()),
|
||
);
|
||
if inner.height > 3 {
|
||
let mid_y = inner.y + inner.height / 2;
|
||
buf.set_string(
|
||
inner.x,
|
||
mid_y,
|
||
&mid_label,
|
||
Style::default().fg(colors.text_pending()),
|
||
);
|
||
}
|
||
buf.set_string(
|
||
inner.x,
|
||
inner.y + inner.height - 1,
|
||
"0",
|
||
Style::default().fg(colors.text_pending()),
|
||
);
|
||
|
||
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 = chart_x + i as u16 * bar_spacing;
|
||
if x >= chart_x + chart_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));
|
||
}
|
||
}
|
||
if bar_height == 0 {
|
||
let y = inner.y + inner.height - 1;
|
||
buf.set_string(x, y, "▁", Style::default().fg(colors.text_pending()));
|
||
}
|
||
|
||
// 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()
|
||
.take(50)
|
||
.enumerate()
|
||
.map(|(i, r)| (i as f64, r.accuracy))
|
||
.collect();
|
||
|
||
if data.is_empty() {
|
||
let block = Block::bordered()
|
||
.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()))
|
||
.style(Style::default().bg(colors.bg()));
|
||
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()).bg(colors.bg()))
|
||
.data(&data);
|
||
|
||
let chart = Chart::new(vec![dataset])
|
||
.style(Style::default().fg(colors.fg()).bg(colors.bg()))
|
||
.block(
|
||
Block::bordered()
|
||
.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()))
|
||
.style(Style::default().bg(colors.bg())),
|
||
)
|
||
.x_axis(
|
||
Axis::default()
|
||
.title("Drill #")
|
||
.style(Style::default().fg(colors.text_pending()).bg(colors.bg()))
|
||
.bounds([0.0, max_x]),
|
||
)
|
||
.y_axis(
|
||
Axis::default()
|
||
.title("Accuracy %")
|
||
.style(Style::default().fg(colors.text_pending()).bg(colors.bg()))
|
||
.labels(vec![
|
||
Span::styled(
|
||
"80",
|
||
Style::default().fg(colors.text_pending()).bg(colors.bg()),
|
||
),
|
||
Span::styled(
|
||
"90",
|
||
Style::default().fg(colors.text_pending()).bg(colors.bg()),
|
||
),
|
||
Span::styled(
|
||
"100",
|
||
Style::default().fg(colors.text_pending()).bg(colors.bg()),
|
||
),
|
||
])
|
||
.bounds([80.0, 100.0]),
|
||
);
|
||
|
||
chart.render(area, buf);
|
||
}
|
||
|
||
fn render_progress_bars(&self, area: Rect, buf: &mut Buffer) {
|
||
let colors = &self.theme.colors;
|
||
|
||
let layout = Layout::default()
|
||
.direction(Direction::Horizontal)
|
||
.constraints([
|
||
Constraint::Percentage(33),
|
||
Constraint::Percentage(34),
|
||
Constraint::Percentage(33),
|
||
])
|
||
.split(area);
|
||
|
||
let avg_wpm = self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
|
||
let avg_accuracy =
|
||
self.history.iter().map(|r| r.accuracy).sum::<f64>() / self.history.len() as f64;
|
||
|
||
// 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,
|
||
wpm_color,
|
||
colors.bar_empty(),
|
||
layout[0],
|
||
buf,
|
||
);
|
||
|
||
// Accuracy progress
|
||
let acc_pct = avg_accuracy.min(100.0);
|
||
let acc_label = format!(" Acc: {acc_pct:.1}%");
|
||
let acc_color = if acc_pct >= 95.0 {
|
||
colors.success()
|
||
} else if acc_pct >= 85.0 {
|
||
colors.warning()
|
||
} else {
|
||
colors.error()
|
||
};
|
||
render_text_bar(
|
||
&acc_label,
|
||
acc_pct / 100.0,
|
||
acc_color,
|
||
colors.bar_empty(),
|
||
layout[1],
|
||
buf,
|
||
);
|
||
|
||
// Overall key progress (unlocked coverage + mastered detail).
|
||
let key_pct = if self.overall_total > 0 {
|
||
self.overall_unlocked as f64 / self.overall_total as f64
|
||
} else {
|
||
0.0
|
||
};
|
||
let level_label = format!(
|
||
" Keys: {}/{} ({} mastered)",
|
||
self.overall_unlocked, self.overall_total, self.overall_mastered
|
||
);
|
||
render_text_bar(
|
||
&level_label,
|
||
key_pct,
|
||
colors.focused_key(),
|
||
colors.bar_empty(),
|
||
layout[2],
|
||
buf,
|
||
);
|
||
}
|
||
|
||
fn render_history_tab(&self, area: Rect, buf: &mut Buffer) {
|
||
let colors = &self.theme.colors;
|
||
|
||
// Recent tests bordered table
|
||
let table_block = Block::bordered()
|
||
.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/Time Mode Ranked Partial",
|
||
Style::default()
|
||
.fg(colors.accent())
|
||
.add_modifier(Modifier::BOLD),
|
||
)]);
|
||
|
||
let mut lines = vec![
|
||
header,
|
||
Line::from(Span::styled(
|
||
" ─────────────────────────────────────────────────────────────────────",
|
||
Style::default().fg(colors.border()),
|
||
)),
|
||
];
|
||
|
||
let visible_rows = history_visible_rows(table_inner);
|
||
let total = self.history.len();
|
||
let max_scroll = total.saturating_sub(visible_rows);
|
||
let scroll = self.history_scroll.min(max_scroll);
|
||
let end = (scroll + visible_rows).min(total);
|
||
let current_year = Utc::now().year();
|
||
|
||
for display_idx in scroll..end {
|
||
let result = &self.history[total - 1 - display_idx];
|
||
let idx = total - display_idx;
|
||
let raw_wpm = result.cpm / 5.0;
|
||
let time_str = format!("{:.1}s", result.elapsed_secs);
|
||
let date_str = format_history_timestamp(result.timestamp, current_year);
|
||
|
||
let idx_str = format!("{idx:>3}");
|
||
let wpm_str = format!("{:>6.0}", result.wpm);
|
||
let raw_str = format!("{:>6.0}", raw_wpm);
|
||
let acc_str = format!("{:>6.1}%", result.accuracy);
|
||
|
||
// WPM indicator
|
||
let wpm_indicator = if result.wpm >= self.target_wpm as f64 {
|
||
"+"
|
||
} else {
|
||
" "
|
||
};
|
||
|
||
let rank_str = if result.ranked { "yes" } else { "no" };
|
||
let partial_pct = if result.partial {
|
||
result.completion_percent
|
||
} else {
|
||
100.0
|
||
};
|
||
let partial_str = format!("{:>6.0}%", partial_pct);
|
||
let row = format!(
|
||
" {wpm_indicator}{idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str:<14} {mode:<9} {rank_str:<6} {partial_str:>7}",
|
||
mode = result.drill_mode,
|
||
);
|
||
|
||
let acc_color = if result.accuracy >= 95.0 {
|
||
colors.success()
|
||
} else if result.accuracy >= 85.0 {
|
||
colors.warning()
|
||
} else {
|
||
colors.error()
|
||
};
|
||
|
||
let is_selected = display_idx == self.history_selected;
|
||
let style = if is_selected {
|
||
Style::default().fg(acc_color).bg(colors.accent_dim())
|
||
} else if result.partial {
|
||
Style::default().fg(colors.warning())
|
||
} else if !result.ranked {
|
||
// Muted styling for unranked drills
|
||
Style::default().fg(colors.text_pending())
|
||
} else {
|
||
Style::default().fg(acc_color)
|
||
};
|
||
|
||
lines.push(Line::from(Span::styled(row, style)));
|
||
}
|
||
|
||
Paragraph::new(lines).render(table_inner, buf);
|
||
}
|
||
|
||
fn render_keyboard_heatmap(&self, area: Rect, buf: &mut Buffer) {
|
||
let colors = &self.theme.colors;
|
||
|
||
let block = Block::bordered()
|
||
.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 {
|
||
return;
|
||
}
|
||
|
||
let (key_width, key_step) = if inner.width >= required_kbd_width(5, 6) {
|
||
(5, 6)
|
||
} else {
|
||
return;
|
||
};
|
||
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
|
||
let all_rows = &self.keyboard_model.rows;
|
||
let offsets: &[u16] = &[0, 2, 3, 4];
|
||
let kbd_width = all_rows
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, row)| {
|
||
let off = offsets.get(i).copied().unwrap_or(0);
|
||
off + row.len() as u16 * key_step
|
||
})
|
||
.max()
|
||
.unwrap_or(inner.width)
|
||
.min(inner.width);
|
||
let keyboard_x = inner.x + inner.width.saturating_sub(kbd_width) / 2;
|
||
|
||
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);
|
||
|
||
// 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 = keyboard_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 = keyboard_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 = accuracy_color(accuracy, colors);
|
||
|
||
let display = format_accuracy_cell(key, accuracy, key_width);
|
||
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
|
||
}
|
||
}
|
||
// Modifier key stats row below the keyboard, spread across keyboard width
|
||
let mod_y = if show_shifted {
|
||
inner.y + all_rows.len() as u16 * 2 + 1
|
||
} else {
|
||
inner.y + all_rows.len() as u16
|
||
};
|
||
if mod_y < inner.y + inner.height {
|
||
let mod_keys: &[(char, &str)] = &[
|
||
(TAB, display::key_short_label(TAB)),
|
||
(SPACE, display::key_short_label(SPACE)),
|
||
(ENTER, display::key_short_label(ENTER)),
|
||
(BACKSPACE, display::key_short_label(BACKSPACE)),
|
||
];
|
||
let labels: Vec<String> = mod_keys
|
||
.iter()
|
||
.map(|&(key, label)| {
|
||
let accuracy = self.get_key_accuracy(key);
|
||
format_accuracy_cell_label(label, accuracy, key_width)
|
||
})
|
||
.collect();
|
||
let positions = spread_labels(&labels, kbd_width);
|
||
for (i, &(key, _)) in mod_keys.iter().enumerate() {
|
||
let accuracy = self.get_key_accuracy(key);
|
||
let fg_color = accuracy_color(accuracy, colors);
|
||
buf.set_string(
|
||
keyboard_x + positions[i],
|
||
mod_y,
|
||
&labels[i],
|
||
Style::default().fg(fg_color),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn get_key_accuracy(&self, key: char) -> f64 {
|
||
let mut correct = 0usize;
|
||
let mut total = 0usize;
|
||
|
||
for result in self.history {
|
||
for kt in &result.per_key_times {
|
||
if kt.key == key {
|
||
total += 1;
|
||
if kt.correct {
|
||
correct += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if total == 0 {
|
||
return 0.0;
|
||
}
|
||
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 {
|
||
return;
|
||
};
|
||
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
|
||
let all_rows = &self.keyboard_model.rows;
|
||
let offsets: &[u16] = &[0, 2, 3, 4];
|
||
let kbd_width = all_rows
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, row)| {
|
||
let off = offsets.get(i).copied().unwrap_or(0);
|
||
off + row.len() as u16 * key_step
|
||
})
|
||
.max()
|
||
.unwrap_or(inner.width)
|
||
.min(inner.width);
|
||
let keyboard_x = inner.x + inner.width.saturating_sub(kbd_width) / 2;
|
||
|
||
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 = keyboard_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 = keyboard_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));
|
||
}
|
||
}
|
||
// Modifier key stats row below the keyboard, spread across keyboard width
|
||
let mod_y = if show_shifted {
|
||
inner.y + all_rows.len() as u16 * 2 + 1
|
||
} else {
|
||
inner.y + all_rows.len() as u16
|
||
};
|
||
if mod_y < inner.y + inner.height {
|
||
let mod_keys: &[(char, &str)] = &[
|
||
(TAB, display::key_short_label(TAB)),
|
||
(SPACE, display::key_short_label(SPACE)),
|
||
(ENTER, display::key_short_label(ENTER)),
|
||
(BACKSPACE, display::key_short_label(BACKSPACE)),
|
||
];
|
||
let labels: Vec<String> = mod_keys
|
||
.iter()
|
||
.map(|&(key, label)| {
|
||
let time_ms = self.get_key_time_ms(key);
|
||
format_timing_cell_label(label, time_ms, key_width)
|
||
})
|
||
.collect();
|
||
let positions = spread_labels(&labels, kbd_width);
|
||
for (i, &(key, _)) in mod_keys.iter().enumerate() {
|
||
let time_ms = self.get_key_time_ms(key);
|
||
let fg_color = timing_color(time_ms, colors);
|
||
buf.set_string(
|
||
keyboard_x + positions[i],
|
||
mod_y,
|
||
&labels[i],
|
||
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(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);
|
||
|
||
let mut key_times: Vec<(char, f64)> = self
|
||
.key_stats
|
||
.stats
|
||
.iter()
|
||
.filter(|(_, s)| s.sample_count > 0)
|
||
.map(|(&ch, s)| (ch, s.filtered_time_ms))
|
||
.collect();
|
||
key_times.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap().then_with(|| a.0.cmp(&b.0)));
|
||
|
||
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 key_name = display_key_short_fixed(*ch);
|
||
let label = format!(" {key_name} {} ", format_ranked_time(*time));
|
||
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()),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn render_fastest_keys(&self, area: Rect, buf: &mut Buffer) {
|
||
let colors = &self.theme.colors;
|
||
|
||
let block = Block::bordered()
|
||
.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);
|
||
|
||
let mut key_times: Vec<(char, f64)> = self
|
||
.key_stats
|
||
.stats
|
||
.iter()
|
||
.filter(|(_, s)| s.sample_count > 0)
|
||
.map(|(&ch, s)| (ch, s.filtered_time_ms))
|
||
.collect();
|
||
key_times.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap().then_with(|| a.0.cmp(&b.0)));
|
||
|
||
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 key_name = display_key_short_fixed(*ch);
|
||
let label = format!(" {key_name} {} ", format_ranked_time(*time));
|
||
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()),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn render_worst_accuracy_keys(&self, area: Rect, buf: &mut Buffer) {
|
||
let colors = &self.theme.colors;
|
||
|
||
let block = Block::bordered()
|
||
.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);
|
||
|
||
// Collect all keys from keyboard model + modifier keys
|
||
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);
|
||
}
|
||
}
|
||
// Include modifier/whitespace keys
|
||
all_keys.insert(SPACE);
|
||
for &key in MODIFIER_SENTINELS {
|
||
all_keys.insert(key);
|
||
}
|
||
|
||
let mut key_accuracies: Vec<(char, f64)> = all_keys
|
||
.into_iter()
|
||
.filter_map(|ch| {
|
||
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
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
key_accuracies.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap().then_with(|| a.0.cmp(&b.0)));
|
||
|
||
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 key_name = display_key_short_fixed(*ch);
|
||
let label = format!(" {key_name} {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, &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_best_accuracy_keys(&self, area: Rect, buf: &mut Buffer) {
|
||
let colors = &self.theme.colors;
|
||
|
||
let block = Block::bordered()
|
||
.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 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);
|
||
}
|
||
}
|
||
all_keys.insert(SPACE);
|
||
for &key in MODIFIER_SENTINELS {
|
||
all_keys.insert(key);
|
||
}
|
||
|
||
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().then_with(|| a.0.cmp(&b.0)));
|
||
|
||
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 key_name = display_key_short_fixed(*ch);
|
||
let label = format!(" {key_name} {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 day_counts: HashMap<chrono::NaiveDate, usize> = HashMap::new();
|
||
let mut active_days: BTreeSet<chrono::NaiveDate> = BTreeSet::new();
|
||
for r in self.history.iter().filter(|r| !r.partial) {
|
||
let day = r.timestamp.date_naive();
|
||
active_days.insert(day);
|
||
*day_counts.entry(day).or_insert(0) += 1;
|
||
}
|
||
let (current_streak, best_streak) = compute_streaks(&active_days);
|
||
let active_days_count = active_days.len();
|
||
let mut top_days: Vec<(chrono::NaiveDate, usize)> = day_counts.into_iter().collect();
|
||
top_days.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| b.0.cmp(&a.0)));
|
||
|
||
let mut lines = vec![Line::from(vec![
|
||
Span::styled(" Current: ", Style::default().fg(colors.fg())),
|
||
Span::styled(
|
||
format!("{current_streak}d"),
|
||
Style::default()
|
||
.fg(colors.success())
|
||
.add_modifier(Modifier::BOLD),
|
||
),
|
||
Span::styled(" Best: ", Style::default().fg(colors.fg())),
|
||
Span::styled(
|
||
format!("{best_streak}d"),
|
||
Style::default()
|
||
.fg(colors.accent())
|
||
.add_modifier(Modifier::BOLD),
|
||
),
|
||
Span::styled(" Active Days: ", Style::default().fg(colors.fg())),
|
||
Span::styled(
|
||
format!("{active_days_count}"),
|
||
Style::default().fg(colors.text_pending()),
|
||
),
|
||
])];
|
||
|
||
let top_days_text = if top_days.is_empty() {
|
||
" Top Days: none".to_string()
|
||
} else {
|
||
let parts: Vec<String> = top_days
|
||
.iter()
|
||
.take(3)
|
||
.map(|(d, c)| format!("{} ({})", d.format("%Y-%m-%d"), c))
|
||
.collect();
|
||
format!(" Top Days: {}", parts.join(" | "))
|
||
};
|
||
lines.push(Line::from(Span::styled(
|
||
top_days_text,
|
||
Style::default().fg(colors.text_pending()),
|
||
)));
|
||
|
||
Paragraph::new(lines).render(inner, buf);
|
||
}
|
||
|
||
// --- N-grams tab ---
|
||
|
||
fn render_ngram_tab(&self, area: Rect, buf: &mut Buffer) {
|
||
let colors = &self.theme.colors;
|
||
|
||
let data = match self.ngram_data {
|
||
Some(d) => d,
|
||
None => {
|
||
let msg = Paragraph::new(Line::from(Span::styled(
|
||
"Complete some adaptive drills to see n-gram data",
|
||
Style::default().fg(colors.text_pending()),
|
||
)));
|
||
msg.render(area, buf);
|
||
return;
|
||
}
|
||
};
|
||
|
||
let layout = Layout::default()
|
||
.direction(Direction::Vertical)
|
||
.constraints([
|
||
Constraint::Length(4), // focus box
|
||
Constraint::Min(5), // lists
|
||
Constraint::Length(2), // summary
|
||
])
|
||
.split(area);
|
||
|
||
self.render_ngram_focus(data, layout[0], buf);
|
||
|
||
let wide = layout[1].width >= 60;
|
||
if wide {
|
||
let lists = Layout::default()
|
||
.direction(Direction::Horizontal)
|
||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||
.split(layout[1]);
|
||
self.render_error_anomalies(data, lists[0], buf);
|
||
self.render_speed_anomalies(data, lists[1], buf);
|
||
} else {
|
||
// Stacked vertically for narrow terminals
|
||
let available = layout[1].height;
|
||
if available < 10 {
|
||
// Only show error anomalies if very little space
|
||
self.render_error_anomalies(data, layout[1], buf);
|
||
} else {
|
||
let half = available / 2;
|
||
let lists = Layout::default()
|
||
.direction(Direction::Vertical)
|
||
.constraints([Constraint::Length(half), Constraint::Min(0)])
|
||
.split(layout[1]);
|
||
self.render_error_anomalies(data, lists[0], buf);
|
||
self.render_speed_anomalies(data, lists[1], buf);
|
||
}
|
||
}
|
||
self.render_ngram_summary(data, layout[2], buf);
|
||
}
|
||
|
||
fn render_ngram_focus(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
|
||
let colors = &self.theme.colors;
|
||
|
||
let block = Block::bordered()
|
||
.title(Line::from(Span::styled(
|
||
" Active Focus ",
|
||
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 < 1 {
|
||
return;
|
||
}
|
||
|
||
let mut lines = Vec::new();
|
||
|
||
match (&data.focus.char_focus, &data.focus.bigram_focus) {
|
||
(Some(ch), Some((key, anomaly_pct, anomaly_type))) => {
|
||
let bigram_label = format!("\"{}{}\"", key.0[0], key.0[1]);
|
||
// Line 1: both focuses
|
||
lines.push(Line::from(vec![
|
||
Span::styled(" Focus: ", Style::default().fg(colors.fg())),
|
||
Span::styled(
|
||
format!("Char '{ch}'"),
|
||
Style::default()
|
||
.fg(colors.focused_key())
|
||
.add_modifier(Modifier::BOLD),
|
||
),
|
||
Span::styled(" + ", Style::default().fg(colors.fg())),
|
||
Span::styled(
|
||
format!("Bigram {bigram_label}"),
|
||
Style::default()
|
||
.fg(colors.focused_key())
|
||
.add_modifier(Modifier::BOLD),
|
||
),
|
||
]));
|
||
// Line 2: details
|
||
if inner.height >= 2 {
|
||
let type_label = match anomaly_type {
|
||
AnomalyType::Error => "error",
|
||
AnomalyType::Speed => "speed",
|
||
};
|
||
let detail = format!(
|
||
" Char '{ch}': weakest key | Bigram {bigram_label}: {type_label} anomaly {anomaly_pct:.0}%"
|
||
);
|
||
lines.push(Line::from(Span::styled(
|
||
detail,
|
||
Style::default().fg(colors.text_pending()),
|
||
)));
|
||
}
|
||
}
|
||
(Some(ch), None) => {
|
||
lines.push(Line::from(vec![
|
||
Span::styled(" Focus: ", Style::default().fg(colors.fg())),
|
||
Span::styled(
|
||
format!("Char '{ch}'"),
|
||
Style::default()
|
||
.fg(colors.focused_key())
|
||
.add_modifier(Modifier::BOLD),
|
||
),
|
||
]));
|
||
if inner.height >= 2 {
|
||
lines.push(Line::from(Span::styled(
|
||
format!(" Char '{ch}': weakest key, no confirmed bigram anomalies"),
|
||
Style::default().fg(colors.text_pending()),
|
||
)));
|
||
}
|
||
}
|
||
(None, Some((key, anomaly_pct, anomaly_type))) => {
|
||
let bigram_label = format!("\"{}{}\"", key.0[0], key.0[1]);
|
||
let type_label = match anomaly_type {
|
||
AnomalyType::Error => "error",
|
||
AnomalyType::Speed => "speed",
|
||
};
|
||
lines.push(Line::from(vec![
|
||
Span::styled(" Focus: ", Style::default().fg(colors.fg())),
|
||
Span::styled(
|
||
format!("Bigram {bigram_label}"),
|
||
Style::default()
|
||
.fg(colors.focused_key())
|
||
.add_modifier(Modifier::BOLD),
|
||
),
|
||
Span::styled(
|
||
format!(" ({type_label} anomaly: {anomaly_pct:.0}%)"),
|
||
Style::default().fg(colors.text_pending()),
|
||
),
|
||
]));
|
||
}
|
||
(None, None) => {
|
||
lines.push(Line::from(Span::styled(
|
||
" Complete some adaptive drills to see focus data",
|
||
Style::default().fg(colors.text_pending()),
|
||
)));
|
||
}
|
||
}
|
||
|
||
Paragraph::new(lines).render(inner, buf);
|
||
}
|
||
|
||
fn render_anomaly_panel(
|
||
&self,
|
||
title: &str,
|
||
empty_msg: &str,
|
||
rows: &[AnomalyBigramRow],
|
||
is_speed: bool,
|
||
area: Rect,
|
||
buf: &mut Buffer,
|
||
) {
|
||
let colors = &self.theme.colors;
|
||
|
||
let block = Block::bordered()
|
||
.title(Line::from(Span::styled(
|
||
title.to_string(),
|
||
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 < 1 {
|
||
return;
|
||
}
|
||
|
||
if rows.is_empty() {
|
||
buf.set_string(
|
||
inner.x,
|
||
inner.y,
|
||
empty_msg,
|
||
Style::default().fg(colors.text_pending()),
|
||
);
|
||
return;
|
||
}
|
||
|
||
let narrow = inner.width < 30;
|
||
|
||
// Error table: Bigram Anom% Rate Errors Smp Strk
|
||
// Speed table: Bigram Anom% Speed Smp Strk
|
||
let header = if narrow {
|
||
if is_speed {
|
||
" Bgrm Speed Expct Anom%"
|
||
} else {
|
||
" Bgrm Err Smp Rate Exp Anom%"
|
||
}
|
||
} else if is_speed {
|
||
" Bigram Speed Expect Samples Anom%"
|
||
} else {
|
||
" Bigram Errors Samples Rate Expect Anom%"
|
||
};
|
||
buf.set_string(
|
||
inner.x,
|
||
inner.y,
|
||
header,
|
||
Style::default()
|
||
.fg(colors.accent())
|
||
.add_modifier(Modifier::BOLD),
|
||
);
|
||
|
||
let max_rows = (inner.height as usize).saturating_sub(1);
|
||
for (i, row) in rows.iter().take(max_rows).enumerate() {
|
||
let y = inner.y + 1 + i as u16;
|
||
if y >= inner.y + inner.height {
|
||
break;
|
||
}
|
||
|
||
let line = if narrow {
|
||
if is_speed {
|
||
format!(
|
||
" {:>4} {:>3.0}ms {:>3.0}ms {:>4.0}%",
|
||
row.bigram, row.speed_ms, row.expected_baseline, row.anomaly_pct,
|
||
)
|
||
} else {
|
||
format!(
|
||
" {:>4} {:>3} {:>3} {:>3.0}% {:>2.0}% {:>4.0}%",
|
||
row.bigram,
|
||
row.error_count,
|
||
row.sample_count,
|
||
row.error_rate_ema * 100.0,
|
||
row.expected_baseline * 100.0,
|
||
row.anomaly_pct,
|
||
)
|
||
}
|
||
} else if is_speed {
|
||
format!(
|
||
" {:>6} {:>4.0}ms {:>4.0}ms {:>5} {:>4.0}%",
|
||
row.bigram,
|
||
row.speed_ms,
|
||
row.expected_baseline,
|
||
row.sample_count,
|
||
row.anomaly_pct,
|
||
)
|
||
} else {
|
||
format!(
|
||
" {:>6} {:>5} {:>5} {:>4.0}% {:>4.0}% {:>5.0}%",
|
||
row.bigram,
|
||
row.error_count,
|
||
row.sample_count,
|
||
row.error_rate_ema * 100.0,
|
||
row.expected_baseline * 100.0,
|
||
row.anomaly_pct,
|
||
)
|
||
};
|
||
|
||
let color = if row.confirmed {
|
||
colors.error()
|
||
} else {
|
||
colors.warning()
|
||
};
|
||
|
||
buf.set_string(inner.x, y, &line, Style::default().fg(color));
|
||
}
|
||
}
|
||
|
||
fn render_error_anomalies(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
|
||
let title = format!(" Error Anomalies ({}) ", data.error_anomalies.len());
|
||
self.render_anomaly_panel(
|
||
&title,
|
||
" No error anomalies detected",
|
||
&data.error_anomalies,
|
||
false,
|
||
area,
|
||
buf,
|
||
);
|
||
}
|
||
|
||
fn render_speed_anomalies(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
|
||
let title = format!(" Speed Anomalies ({}) ", data.speed_anomalies.len());
|
||
self.render_anomaly_panel(
|
||
&title,
|
||
" No speed anomalies detected",
|
||
&data.speed_anomalies,
|
||
true,
|
||
area,
|
||
buf,
|
||
);
|
||
}
|
||
|
||
fn render_ngram_summary(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
|
||
let colors = &self.theme.colors;
|
||
let w = area.width as usize;
|
||
|
||
let gain_str = match data.latest_trigram_gain {
|
||
Some(g) => format!("{:.1}%", g * 100.0),
|
||
None => "--".to_string(),
|
||
};
|
||
|
||
// Build segments from most to least important, progressively drop from the right
|
||
let scope = format!(" {}", data.scope_label);
|
||
let bigrams = format!(" | Bi: {}", data.total_bigrams);
|
||
let trigrams = format!(" | Tri: {}", data.total_trigrams);
|
||
let hesitation = format!(" | Hes: >{:.0}ms", data.hesitation_threshold_ms);
|
||
let gain = format!(" | Gain: {}", gain_str);
|
||
let gain_note = if data.latest_trigram_gain.is_none() {
|
||
" (every 50)"
|
||
} else {
|
||
""
|
||
};
|
||
|
||
let segments: &[&str] = &[&scope, &bigrams, &trigrams, &hesitation, &gain, gain_note];
|
||
let mut line = String::new();
|
||
for seg in segments {
|
||
if line.len() + seg.len() <= w {
|
||
line.push_str(seg);
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
buf.set_string(
|
||
area.x,
|
||
area.y,
|
||
&line,
|
||
Style::default().fg(colors.text_pending()),
|
||
);
|
||
}
|
||
}
|
||
|
||
const TAB_LABELS: [&str; 6] = [
|
||
"[1] Dashboard",
|
||
"[2] History",
|
||
"[3] Activity",
|
||
"[4] Accuracy",
|
||
"[5] Timing",
|
||
"[6] N-grams",
|
||
];
|
||
const TAB_SEPARATOR: &str = " ";
|
||
const FOOTER_HINTS_DEFAULT: [&str; 3] = ["[ESC] Back", "[Tab] Next tab", "[1-6] Switch tab"];
|
||
const FOOTER_HINTS_HISTORY: [&str; 6] = [
|
||
"[ESC] Back",
|
||
"[Tab] Next tab",
|
||
"[1-6] Switch tab",
|
||
"[j/k] Navigate",
|
||
"[PgUp/PgDn] Page",
|
||
"[x] Delete",
|
||
];
|
||
|
||
fn history_visible_rows(table_inner: Rect) -> usize {
|
||
table_inner.height.saturating_sub(2) as usize
|
||
}
|
||
|
||
fn wrapped_tab_line_count(width: usize) -> usize {
|
||
let mut lines = 1usize;
|
||
let mut current_width = 0usize;
|
||
for label in TAB_LABELS {
|
||
let item_width = format!(" {label} ").chars().count() + TAB_SEPARATOR.len();
|
||
if current_width > 0 && current_width + item_width > width {
|
||
lines += 1;
|
||
current_width = 0;
|
||
}
|
||
current_width += item_width;
|
||
}
|
||
lines.max(1)
|
||
}
|
||
|
||
fn footer_line_count_for_history(width: usize) -> usize {
|
||
pack_hint_lines(&FOOTER_HINTS_HISTORY, width).len().max(1)
|
||
}
|
||
|
||
pub fn history_page_size_for_terminal(width: u16, height: u16) -> usize {
|
||
let inner_width = width.saturating_sub(2) as usize;
|
||
let inner_height = height.saturating_sub(2);
|
||
let tab_lines = wrapped_tab_line_count(inner_width) as u16;
|
||
let footer_lines = footer_line_count_for_history(inner_width) as u16;
|
||
let tab_area_height = inner_height.saturating_sub(tab_lines + footer_lines);
|
||
let table_inner_height = tab_area_height.saturating_sub(2);
|
||
table_inner_height.saturating_sub(2).max(1) as usize
|
||
}
|
||
|
||
fn format_history_timestamp(ts: chrono::DateTime<Utc>, current_year: i32) -> String {
|
||
if ts.year() < current_year {
|
||
ts.format("%m/%d/%y %H:%M").to_string()
|
||
} else {
|
||
ts.format("%m/%d %H:%M").to_string()
|
||
}
|
||
}
|
||
|
||
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 format_accuracy_cell_label(label: &str, accuracy: f64, key_width: u16) -> String {
|
||
if accuracy > 0.0 {
|
||
let pct = accuracy.round() as u32;
|
||
if key_width >= 5 {
|
||
format!("{label} {pct:<3}")
|
||
} else {
|
||
format!("{label}{pct:>2}")
|
||
}
|
||
} else if key_width >= 5 {
|
||
format!("{label} ")
|
||
} else {
|
||
format!("{label} ")
|
||
}
|
||
}
|
||
|
||
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 display_key_short_fixed(ch: char) -> String {
|
||
let special = display::key_short_label(ch);
|
||
let raw = if special.is_empty() {
|
||
ch.to_string()
|
||
} else {
|
||
special.to_string()
|
||
};
|
||
format!("{raw:<4}")
|
||
}
|
||
|
||
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 value = format_timing_visual_value_3(time_ms);
|
||
if key_width >= 5 {
|
||
format!("{key} {value:<3}")
|
||
} else {
|
||
format!("{key}{value:>3}")
|
||
}
|
||
} else if key_width >= 5 {
|
||
format!("{key} ")
|
||
} else {
|
||
format!("{key} ")
|
||
}
|
||
}
|
||
|
||
fn format_timing_cell_label(label: &str, time_ms: f64, key_width: u16) -> String {
|
||
if time_ms > 0.0 {
|
||
let value = format_timing_visual_value_3(time_ms);
|
||
if key_width >= 5 {
|
||
format!("{label} {value:<3}")
|
||
} else {
|
||
format!("{label}{value:>3}")
|
||
}
|
||
} else if key_width >= 5 {
|
||
format!("{label} ")
|
||
} else {
|
||
format!("{label} ")
|
||
}
|
||
}
|
||
|
||
fn format_timing_visual_value_3(time_ms: f64) -> String {
|
||
let ms = time_ms.max(0.0).round() as u32;
|
||
if ms <= 999 {
|
||
return format!("{ms:>3}");
|
||
}
|
||
|
||
// Keep visualizer values to exactly 3 chars while signaling second units.
|
||
// Example: 1.2s => "1s2", 9.0s => "9s0", 12s => "12s".
|
||
if ms < 10_000 {
|
||
let tenths = ((ms as f64 / 100.0).round() as u32).min(99);
|
||
let whole = tenths / 10;
|
||
let frac = tenths % 10;
|
||
return format!("{whole}s{frac}");
|
||
}
|
||
|
||
let secs = ((ms as f64) / 1000.0).round() as u32;
|
||
format!("{:>3}", format!("{}s", secs.min(99)))
|
||
}
|
||
|
||
fn format_ranked_time(time_ms: f64) -> String {
|
||
if time_ms > 59_999.0 {
|
||
return format!("{:.1}m", time_ms / 60_000.0);
|
||
}
|
||
if time_ms > 9_999.0 {
|
||
return format!("{:.1}s", time_ms / 1_000.0);
|
||
}
|
||
format!("{time_ms:>4.0}ms")
|
||
}
|
||
|
||
/// Distribute labels across `total_width`, with the first flush-left
|
||
/// and the last flush-right, and equal gaps between the rest.
|
||
fn spread_labels(labels: &[String], total_width: u16) -> Vec<u16> {
|
||
let n = labels.len();
|
||
if n == 0 {
|
||
return vec![];
|
||
}
|
||
if n == 1 {
|
||
return vec![0];
|
||
}
|
||
let total_label_width: u16 = labels.iter().map(|l| l.len() as u16).sum();
|
||
let last_width = labels.last().map(|l| l.len() as u16).unwrap_or(0);
|
||
let spare = total_width.saturating_sub(total_label_width);
|
||
let gaps = (n - 1) as u16;
|
||
let gap = if gaps > 0 { spare / gaps } else { 0 };
|
||
let remainder = if gaps > 0 { spare % gaps } else { 0 };
|
||
|
||
let mut positions = Vec::with_capacity(n);
|
||
let mut x: u16 = 0;
|
||
for (i, label) in labels.iter().enumerate() {
|
||
if i == n - 1 {
|
||
// Last label flush-right
|
||
x = total_width.saturating_sub(last_width);
|
||
}
|
||
positions.push(x);
|
||
x += label.len() as u16 + gap + if (i as u16) < remainder { 1 } else { 0 };
|
||
}
|
||
positions
|
||
}
|
||
|
||
fn render_text_bar(
|
||
label: &str,
|
||
ratio: f64,
|
||
fill_color: ratatui::style::Color,
|
||
empty_color: ratatui::style::Color,
|
||
area: Rect,
|
||
buf: &mut Buffer,
|
||
) {
|
||
if area.height < 2 || area.width < 10 {
|
||
return;
|
||
}
|
||
|
||
// Label on first line
|
||
buf.set_string(area.x, area.y, label, Style::default().fg(fill_color));
|
||
|
||
// 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;
|
||
|
||
let bar_y = area.y + 1;
|
||
buf.set_string(area.x, bar_y, " ", Style::default());
|
||
|
||
for i in 0..bar_width {
|
||
let x = area.x + 2 + i as u16;
|
||
if x >= area.x + area.width {
|
||
break;
|
||
}
|
||
let (ch, color) = if i < filled {
|
||
('█', fill_color)
|
||
} else {
|
||
('░', empty_color)
|
||
};
|
||
buf.set_string(x, bar_y, &ch.to_string(), Style::default().fg(color));
|
||
}
|
||
}
|
||
|
||
fn format_duration(secs: f64) -> String {
|
||
let total = secs as u64;
|
||
let hours = total / 3600;
|
||
let mins = (total % 3600) / 60;
|
||
let s = total % 60;
|
||
if hours > 0 {
|
||
format!("{hours}h {mins}m {s}s")
|
||
} else if mins > 0 {
|
||
format!("{mins}m {s}s")
|
||
} else {
|
||
format!("{s}s")
|
||
}
|
||
}
|
||
|
||
/// Compute the ngram tab panel layout for the given terminal area.
|
||
/// Returns `(wide, lists_area_height)` where:
|
||
/// - `wide` = true means side-by-side anomaly panels (width >= 60)
|
||
/// - `lists_area_height` = height available for the anomaly panels region
|
||
///
|
||
/// When `!wide && lists_area_height < 10`, only error anomalies should render.
|
||
#[cfg(test)]
|
||
fn ngram_panel_layout(area: Rect) -> (bool, u16) {
|
||
let layout = Layout::default()
|
||
.direction(Direction::Vertical)
|
||
.constraints([
|
||
Constraint::Length(4), // focus box
|
||
Constraint::Min(5), // lists
|
||
Constraint::Length(2), // summary
|
||
])
|
||
.split(area);
|
||
let wide = layout[1].width >= 60;
|
||
(wide, layout[1].height)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use chrono::TimeZone;
|
||
|
||
#[test]
|
||
fn narrow_short_terminal_shows_only_error_panel() {
|
||
// 50 cols × 15 rows: narrow (<60) so panels stack vertically.
|
||
// lists area = 15 - 4 (focus) - 2 (summary) = 9 rows → < 10 → error only.
|
||
let area = Rect::new(0, 0, 50, 15);
|
||
let (wide, lists_height) = ngram_panel_layout(area);
|
||
assert!(!wide, "50 cols should be narrow layout");
|
||
assert!(
|
||
lists_height < 10,
|
||
"lists_height={lists_height}, expected < 10 so only error panel renders"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn narrow_tall_terminal_stacks_both_panels() {
|
||
// 50 cols × 30 rows: narrow (<60) so panels stack vertically.
|
||
// lists area = 30 - 4 - 2 = 24 rows → >= 10 → both panels stacked.
|
||
let area = Rect::new(0, 0, 50, 30);
|
||
let (wide, lists_height) = ngram_panel_layout(area);
|
||
assert!(!wide, "50 cols should be narrow layout");
|
||
assert!(
|
||
lists_height >= 10,
|
||
"lists_height={lists_height}, expected >= 10 so both panels stack vertically"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wide_terminal_shows_side_by_side_panels() {
|
||
// 80 cols × 24 rows: wide (>= 60) so panels render side by side.
|
||
let area = Rect::new(0, 0, 80, 24);
|
||
let (wide, _) = ngram_panel_layout(area);
|
||
assert!(
|
||
wide,
|
||
"80 cols should be wide layout with side-by-side panels"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn boundary_width_59_is_narrow() {
|
||
let area = Rect::new(0, 0, 59, 24);
|
||
let (wide, _) = ngram_panel_layout(area);
|
||
assert!(!wide, "59 cols should be narrow");
|
||
}
|
||
|
||
#[test]
|
||
fn boundary_width_60_is_wide() {
|
||
let area = Rect::new(0, 0, 60, 24);
|
||
let (wide, _) = ngram_panel_layout(area);
|
||
assert!(wide, "60 cols should be wide");
|
||
}
|
||
|
||
#[test]
|
||
fn history_page_size_is_positive() {
|
||
let page = history_page_size_for_terminal(80, 24);
|
||
assert!(page >= 1, "history page size should be at least 1");
|
||
}
|
||
|
||
#[test]
|
||
fn history_page_size_grows_with_terminal_height() {
|
||
let short_page = history_page_size_for_terminal(100, 20);
|
||
let tall_page = history_page_size_for_terminal(100, 40);
|
||
assert!(
|
||
tall_page > short_page,
|
||
"expected taller terminal to show more rows ({short_page} -> {tall_page})"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn history_date_shows_year_for_previous_year_sessions() {
|
||
let ts = Utc.with_ymd_and_hms(2025, 12, 31, 23, 59, 0).unwrap();
|
||
let display = format_history_timestamp(ts, 2026);
|
||
assert!(
|
||
display.starts_with("12/31/25"),
|
||
"expected MM/DD/YY format for prior-year session: {display}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn history_date_omits_year_for_current_year_sessions() {
|
||
let ts = Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 0).unwrap();
|
||
let display = format_history_timestamp(ts, 2026);
|
||
assert!(
|
||
!display.starts_with("2026-"),
|
||
"did not expect year prefix for current-year session: {display}"
|
||
);
|
||
}
|
||
}
|