Various UI fixes, better capital letter injection, paginated history

This commit is contained in:
2026-02-28 05:07:33 +00:00
parent c67ddf577a
commit b37dc72b45
6 changed files with 499 additions and 98 deletions

View File

@@ -222,6 +222,7 @@ pub struct App {
pub depressed_keys: HashSet<char>, pub depressed_keys: HashSet<char>,
pub last_key_time: Option<Instant>, pub last_key_time: Option<Instant>,
pub history_selected: usize, pub history_selected: usize,
pub history_scroll: usize,
pub history_confirm_delete: bool, pub history_confirm_delete: bool,
pub skill_tree_selected: usize, pub skill_tree_selected: usize,
pub skill_tree_detail_scroll: usize, pub skill_tree_detail_scroll: usize,
@@ -373,6 +374,7 @@ impl App {
depressed_keys: HashSet::new(), depressed_keys: HashSet::new(),
last_key_time: None, last_key_time: None,
history_selected: 0, history_selected: 0,
history_scroll: 0,
history_confirm_delete: false, history_confirm_delete: false,
skill_tree_selected: 0, skill_tree_selected: 0,
skill_tree_detail_scroll: 0, skill_tree_detail_scroll: 0,
@@ -719,8 +721,9 @@ impl App {
.filter(|ch| ch.is_ascii_lowercase() || *ch == ' ') .filter(|ch| ch.is_ascii_lowercase() || *ch == ' ')
.collect(); .collect();
let filter = CharFilter::new(lowercase_keys); let filter = CharFilter::new(lowercase_keys);
// Only pass focused to phonetic generator if it's a lowercase letter // Feed uppercase focus as lowercase so capitals drills bias base word content
let lowercase_focused = focused_char.filter(|ch| ch.is_ascii_lowercase()); // the same way other focused key types bias their generators.
let lowercase_focused = lowercase_generation_focus(focused_char);
let table = self.transition_table.clone(); let table = self.transition_table.clone();
let dict = Dictionary::load(); let dict = Dictionary::load();
let rng = SmallRng::from_rng(&mut self.rng).unwrap(); let rng = SmallRng::from_rng(&mut self.rng).unwrap();
@@ -1417,6 +1420,7 @@ impl App {
self.clear_post_drill_input_lock(); self.clear_post_drill_input_lock();
self.stats_tab = 0; self.stats_tab = 0;
self.history_selected = 0; self.history_selected = 0;
self.history_scroll = 0;
self.history_confirm_delete = false; self.history_confirm_delete = false;
self.screen = AppScreen::StatsDashboard; self.screen = AppScreen::StatsDashboard;
} }
@@ -1431,16 +1435,20 @@ impl App {
self.rebuild_from_history(); self.rebuild_from_history();
self.save_data(); self.save_data();
// Clamp selection to visible range (max 20 visible rows) // Clamp selection to full history range
if !self.drill_history.is_empty() { if !self.drill_history.is_empty() {
let max_visible = self.drill_history.len().min(20) - 1; let max_idx = self.drill_history.len() - 1;
self.history_selected = self.history_selected.min(max_visible); self.history_selected = self.history_selected.min(max_idx);
self.history_scroll = self.history_scroll.min(self.history_selected);
} else { } else {
self.history_selected = 0; self.history_selected = 0;
self.history_scroll = 0;
} }
} }
pub fn rebuild_from_history(&mut self) { pub fn rebuild_from_history(&mut self) {
let previous_progress = self.profile.skill_tree.clone();
// Reset all derived state // Reset all derived state
self.key_stats = KeyStatsStore::default(); self.key_stats = KeyStatsStore::default();
self.key_stats.target_cpm = self.config.target_cpm(); self.key_stats.target_cpm = self.config.target_cpm();
@@ -1503,6 +1511,9 @@ impl App {
} }
} }
// Prevent destructive regressions when rebuilding from history:
// preserve any previously reached branch status/level.
merge_skill_tree_progress_non_regressive(&mut self.skill_tree, &previous_progress);
self.profile.skill_tree = self.skill_tree.progress.clone(); self.profile.skill_tree = self.skill_tree.progress.clone();
// Rebuild n-gram stats from the replayed history // Rebuild n-gram stats from the replayed history
@@ -2152,6 +2163,31 @@ impl App {
} }
} }
fn branch_status_rank(status: &BranchStatus) -> u8 {
match status {
BranchStatus::Locked => 0,
BranchStatus::Available => 1,
BranchStatus::InProgress => 2,
BranchStatus::Complete => 3,
}
}
fn merge_skill_tree_progress_non_regressive(
skill_tree: &mut SkillTree,
previous: &crate::engine::skill_tree::SkillTreeProgress,
) {
for id in crate::engine::skill_tree::BranchId::all() {
let Some(prev) = previous.branches.get(id.to_key()) else {
continue;
};
let curr = skill_tree.branch_progress_mut(*id);
if branch_status_rank(&curr.status) < branch_status_rank(&prev.status) {
curr.status = prev.status.clone();
}
curr.current_level = curr.current_level.max(prev.current_level);
}
}
/// Insert newlines at sentence boundaries (~60-80 chars per line). /// Insert newlines at sentence boundaries (~60-80 chars per line).
fn insert_line_breaks(text: &str) -> String { fn insert_line_breaks(text: &str) -> String {
let mut result = String::with_capacity(text.len()); let mut result = String::with_capacity(text.len());
@@ -2182,6 +2218,18 @@ fn insert_line_breaks(text: &str) -> String {
result result
} }
fn lowercase_generation_focus(focused: Option<char>) -> Option<char> {
focused.and_then(|ch| {
if ch.is_ascii_lowercase() {
Some(ch)
} else if ch.is_ascii_uppercase() {
Some(ch.to_ascii_lowercase())
} else {
None
}
})
}
#[cfg(test)] #[cfg(test)]
impl App { impl App {
pub fn new_test() -> Self { pub fn new_test() -> Self {
@@ -2215,6 +2263,7 @@ impl App {
depressed_keys: HashSet::new(), depressed_keys: HashSet::new(),
last_key_time: None, last_key_time: None,
history_selected: 0, history_selected: 0,
history_scroll: 0,
history_confirm_delete: false, history_confirm_delete: false,
skill_tree_selected: 0, skill_tree_selected: 0,
skill_tree_detail_scroll: 0, skill_tree_detail_scroll: 0,
@@ -2476,4 +2525,37 @@ mod tests {
"Input lock should be armed for milestone path" "Input lock should be armed for milestone path"
); );
} }
#[test]
fn rebuild_from_history_preserves_previous_branch_unlocks() {
let mut app = App::new_test();
// Simulate previously unlocked branch progress with sparse history replay input.
if let Some(bp) = app
.profile
.skill_tree
.branches
.get_mut(BranchId::Capitals.to_key())
{
bp.status = BranchStatus::InProgress;
bp.current_level = 1;
}
app.skill_tree = SkillTree::new(app.profile.skill_tree.clone());
// No additional ranked drills to advance tree during replay.
app.drill_history.clear();
app.rebuild_from_history();
let capitals = app.skill_tree.branch_progress(BranchId::Capitals);
assert_eq!(capitals.status, BranchStatus::InProgress);
assert!(capitals.current_level >= 1);
}
#[test]
fn uppercase_focus_maps_to_lowercase_for_base_generation() {
assert_eq!(lowercase_generation_focus(Some('w')), Some('w'));
assert_eq!(lowercase_generation_focus(Some('W')), Some('w'));
assert_eq!(lowercase_generation_focus(Some('7')), None);
assert_eq!(lowercase_generation_focus(None), None);
}
} }

View File

@@ -220,7 +220,10 @@ fn lowercase_keys(count: usize) -> Vec<char> {
/// Base date for all profiles. /// Base date for all profiles.
fn base_date() -> DateTime<Utc> { fn base_date() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2025, 1, 1, 8, 0, 0).unwrap() // Keep generated fixtures in the recent past so activity heatmaps that show
// only recent weeks still light up when importing test profiles.
let start_day = Utc::now().date_naive() - chrono::Duration::days(100);
Utc.from_utc_datetime(&start_day.and_hms_opt(8, 0, 0).unwrap())
} }
/// Generate drill history spread across `streak_days` days. /// Generate drill history spread across `streak_days` days.

View File

@@ -64,9 +64,54 @@ pub fn apply_capitalization(
result.push(ch); result.push(ch);
} }
// Focused capitals should show up multiple times when possible so they are
// introduced at a similar density to other focused key types.
if let Some(focused_upper) = focused_upper.filter(|ch| unlocked_capitals.contains(ch)) {
return ensure_min_focused_occurrences(&result, focused_upper, 3);
}
result result
} }
fn ensure_min_focused_occurrences(text: &str, focused_upper: char, min_count: usize) -> String {
let focused_lower = focused_upper.to_ascii_lowercase();
let mut chars: Vec<char> = text.chars().collect();
let mut count = chars.iter().filter(|&&ch| ch == focused_upper).count();
if count >= min_count {
return text.to_string();
}
// First, capitalize matching word starts.
for i in 0..chars.len() {
if count >= min_count {
break;
}
if chars[i] != focused_lower {
continue;
}
let is_word_start = i == 0
|| matches!(chars.get(i.saturating_sub(1)), Some(' ' | '\n' | '\t'));
if is_word_start {
chars[i] = focused_upper;
count += 1;
}
}
// If still short, capitalize matching letters anywhere in the text.
for ch in &mut chars {
if count >= min_count {
break;
}
if *ch == focused_lower {
*ch = focused_upper;
count += 1;
}
}
chars.into_iter().collect()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -125,4 +170,16 @@ mod tests {
"Focused W count ({focused_count}) should exceed unfocused ({unfocused_count})" "Focused W count ({focused_count}) should exceed unfocused ({unfocused_count})"
); );
} }
#[test]
fn test_focused_capital_has_minimum_presence_when_available() {
let mut rng = SmallRng::seed_from_u64(123);
let text = "we will work with weird words while we wait";
let result = apply_capitalization(text, &['W'], Some('W'), &mut rng);
let focused_count = result.chars().filter(|&ch| ch == 'W').count();
assert!(
focused_count >= 3,
"Expected at least 3 focused capitals, got {focused_count} in: {result}"
);
}
} }

View File

@@ -40,7 +40,9 @@ use ui::components::dashboard::Dashboard;
use ui::layout::{pack_hint_lines, wrapped_line_count}; use ui::layout::{pack_hint_lines, wrapped_line_count};
use ui::components::keyboard_diagram::KeyboardDiagram; use ui::components::keyboard_diagram::KeyboardDiagram;
use ui::components::skill_tree::{SkillTreeWidget, detail_line_count, selectable_branches}; use ui::components::skill_tree::{SkillTreeWidget, detail_line_count, selectable_branches};
use ui::components::stats_dashboard::{AnomalyBigramRow, NgramTabData, StatsDashboard}; use ui::components::stats_dashboard::{
AnomalyBigramRow, NgramTabData, StatsDashboard, history_page_size_for_terminal,
};
use ui::components::stats_sidebar::StatsSidebar; use ui::components::stats_sidebar::StatsSidebar;
use ui::components::typing_area::TypingArea; use ui::components::typing_area::TypingArea;
use ui::layout::AppLayout; use ui::layout::AppLayout;
@@ -450,16 +452,30 @@ fn handle_stats_key(app: &mut App, key: KeyEvent) {
// History tab has row navigation // History tab has row navigation
if app.stats_tab == 1 { if app.stats_tab == 1 {
let page_size = current_history_page_size();
match key.code { match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
KeyCode::Char('j') | KeyCode::Down => { KeyCode::Char('j') | KeyCode::Down => {
if !app.drill_history.is_empty() { if !app.drill_history.is_empty() {
let max_visible = app.drill_history.len().min(20) - 1; let max_idx = app.drill_history.len() - 1;
app.history_selected = (app.history_selected + 1).min(max_visible); app.history_selected = (app.history_selected + 1).min(max_idx);
keep_history_selection_visible(app, page_size);
} }
} }
KeyCode::Char('k') | KeyCode::Up => { KeyCode::Char('k') | KeyCode::Up => {
app.history_selected = app.history_selected.saturating_sub(1); app.history_selected = app.history_selected.saturating_sub(1);
keep_history_selection_visible(app, page_size);
}
KeyCode::PageDown => {
if !app.drill_history.is_empty() {
let max_idx = app.drill_history.len() - 1;
app.history_selected = (app.history_selected + page_size).min(max_idx);
keep_history_selection_visible(app, page_size);
}
}
KeyCode::PageUp => {
app.history_selected = app.history_selected.saturating_sub(page_size);
keep_history_selection_visible(app, page_size);
} }
KeyCode::Char('x') | KeyCode::Delete => { KeyCode::Char('x') | KeyCode::Delete => {
if !app.drill_history.is_empty() { if !app.drill_history.is_empty() {
@@ -1234,16 +1250,6 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
}) })
.collect(); .collect();
let progress_height = if show_progress && area.height >= 25 {
(active_branches.len().min(6) as u16 + 1).max(2) // +1 for overall line
} else if show_progress && area.height >= 20 {
2 // active branch + overall
} else if show_progress {
1 // active branch only
} else {
0
};
let kbd_height = if show_kbd { let kbd_height = if show_kbd {
if tier.compact_keyboard() { if tier.compact_keyboard() {
6 // 3 rows + 2 border + 1 modifier space 6 // 3 rows + 2 border + 1 modifier space
@@ -1254,6 +1260,35 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
0 0
}; };
let progress_height = if show_progress {
// Adaptive progress can use: branch rows + optional separator + overall line.
// Prefer the separator when space allows, but degrade if constrained.
let branch_rows = if area.height >= 25 {
ui::components::branch_progress_list::wrapped_branch_rows(
app_layout.main.width,
active_branches.len(),
)
} else if !active_branches.is_empty() {
1
} else {
0
};
let desired = if app.drill_mode == DrillMode::Adaptive {
(branch_rows + 2).max(2)
} else {
1
};
// Keep at least 5 lines for typing area.
let max_budget = app_layout
.main
.height
.saturating_sub(kbd_height)
.saturating_sub(5);
desired.min(max_budget)
} else {
0
};
let mut constraints: Vec<Constraint> = vec![Constraint::Min(5)]; let mut constraints: Vec<Constraint> = vec![Constraint::Min(5)];
if progress_height > 0 { if progress_height > 0 {
constraints.push(Constraint::Length(progress_height)); constraints.push(Constraint::Length(progress_height));
@@ -2523,6 +2558,7 @@ fn render_stats(frame: &mut ratatui::Frame, app: &App) {
app.skill_tree.total_unique_keys, app.skill_tree.total_unique_keys,
app.theme, app.theme,
app.history_selected, app.history_selected,
app.history_scroll,
app.history_confirm_delete, app.history_confirm_delete,
&app.keyboard_model, &app.keyboard_model,
ngram_data.as_ref(), ngram_data.as_ref(),
@@ -2530,6 +2566,22 @@ fn render_stats(frame: &mut ratatui::Frame, app: &App) {
frame.render_widget(dashboard, area); frame.render_widget(dashboard, area);
} }
fn keep_history_selection_visible(app: &mut App, page_size: usize) {
let viewport = page_size.max(1);
if app.history_selected < app.history_scroll {
app.history_scroll = app.history_selected;
} else if app.history_selected >= app.history_scroll + viewport {
app.history_scroll = app.history_selected + 1 - viewport;
}
}
fn current_history_page_size() -> usize {
match crossterm::terminal::size() {
Ok((w, h)) => history_page_size_for_terminal(w, h),
Err(_) => 10,
}
}
fn build_ngram_tab_data(app: &App) -> NgramTabData { fn build_ngram_tab_data(app: &App) -> NgramTabData {
use engine::ngram_stats::{self, select_focus}; use engine::ngram_stats::{self, select_focus};

View File

@@ -16,6 +16,28 @@ pub struct BranchProgressList<'a> {
pub height: u16, pub height: u16,
} }
const MIN_BRANCH_CELL_WIDTH: usize = 28;
const BRANCH_CELL_GUTTER: usize = 1;
pub fn wrapped_branch_rows(area_width: u16, branch_count: usize) -> u16 {
if branch_count == 0 {
return 0;
}
let columns = wrapped_branch_columns(area_width, branch_count);
branch_count.div_ceil(columns) as u16
}
fn wrapped_branch_columns(area_width: u16, branch_count: usize) -> usize {
if branch_count == 0 {
return 1;
}
let width = area_width as usize;
let max_cols_by_width = ((width + BRANCH_CELL_GUTTER)
/ (MIN_BRANCH_CELL_WIDTH + BRANCH_CELL_GUTTER))
.max(1);
max_cols_by_width.min(branch_count)
}
impl Widget for BranchProgressList<'_> { impl Widget for BranchProgressList<'_> {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
@@ -26,43 +48,40 @@ impl Widget for BranchProgressList<'_> {
DrillScope::Global => None, DrillScope::Global => None,
}; };
let show_all = self.height > 2; let show_all = should_render_branch_rows(self.height, self.active_branches.len());
if show_all { if show_all {
for &branch_id in self.active_branches { let columns = wrapped_branch_columns(area.width, self.active_branches.len());
let rows = self.active_branches.len().div_ceil(columns);
let available_width = area.width as usize;
let total_gutter = BRANCH_CELL_GUTTER.saturating_mul(columns.saturating_sub(1));
let cell_width = available_width.saturating_sub(total_gutter) / columns;
for row in 0..rows {
if lines.len() as u16 >= self.height.saturating_sub(1) { if lines.len() as u16 >= self.height.saturating_sub(1) {
break; break;
} }
let def = get_branch_definition(branch_id);
let total = SkillTree::branch_total_keys(branch_id); let mut spans: Vec<Span> = Vec::new();
let unlocked = self.skill_tree.branch_unlocked_count(branch_id); for col in 0..columns {
let mastered = self let idx = row * columns + col;
.skill_tree if idx >= self.active_branches.len() {
.branch_confident_keys(branch_id, self.key_stats); break;
let is_active = drill_branch == Some(branch_id); }
let prefix = if is_active { if col > 0 {
" \u{25b6} " spans.push(Span::raw(" ".repeat(BRANCH_CELL_GUTTER)));
} else { }
" \u{00b7} " let branch_id = self.active_branches[idx];
}; spans.extend(render_branch_cell(
let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12); branch_id,
let name = format!("{:<14}", def.name); drill_branch == Some(branch_id),
let label_color = if is_active { cell_width,
colors.accent() self.skill_tree,
} else { self.key_stats,
colors.text_pending() self.theme,
}; ));
lines.push(Line::from(vec![ }
Span::styled(prefix, Style::default().fg(label_color)), lines.push(Line::from(spans));
Span::styled(name, Style::default().fg(label_color)),
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
Span::styled(u_bar, Style::default().fg(colors.accent())),
Span::styled(e_bar, Style::default().fg(colors.text_pending())),
Span::styled(
format!(" {unlocked}/{total}"),
Style::default().fg(colors.text_pending()),
),
]));
} }
} else if let Some(branch_id) = drill_branch { } else if let Some(branch_id) = drill_branch {
let def = get_branch_definition(branch_id); let def = get_branch_definition(branch_id);
@@ -89,6 +108,9 @@ impl Widget for BranchProgressList<'_> {
// Overall line // Overall line
if lines.len() < self.height as usize { if lines.len() < self.height as usize {
if should_insert_overall_separator(lines.len(), self.height as usize) {
lines.push(Line::from(""));
}
let total = self.skill_tree.total_unique_keys; let total = self.skill_tree.total_unique_keys;
let unlocked = self.skill_tree.total_unlocked_count(); let unlocked = self.skill_tree.total_unlocked_count();
let mastered = self.skill_tree.total_confident_keys(self.key_stats); let mastered = self.skill_tree.total_confident_keys(self.key_stats);
@@ -125,6 +147,75 @@ impl Widget for BranchProgressList<'_> {
} }
} }
fn should_render_branch_rows(height: u16, active_branch_count: usize) -> bool {
active_branch_count > 0 && height > 1
}
fn should_insert_overall_separator(current_lines: usize, total_height: usize) -> bool {
current_lines > 0 && current_lines + 2 <= total_height
}
fn render_branch_cell<'a>(
branch_id: BranchId,
is_active: bool,
cell_width: usize,
skill_tree: &SkillTree,
key_stats: &crate::engine::key_stats::KeyStatsStore,
theme: &'a Theme,
) -> Vec<Span<'a>> {
let colors = &theme.colors;
let def = get_branch_definition(branch_id);
let total = SkillTree::branch_total_keys(branch_id);
let unlocked = skill_tree.branch_unlocked_count(branch_id);
let mastered = skill_tree.branch_confident_keys(branch_id, key_stats);
let prefix = if is_active { "\u{25b6} " } else { "\u{00b7} " };
let label_color = if is_active {
colors.accent()
} else {
colors.text_pending()
};
let count = format!("{unlocked}/{total}");
let name_width = if cell_width >= 34 {
14
} else if cell_width >= 30 {
12
} else {
10
};
let fixed = prefix.len() + name_width + 1 + count.len();
let bar_width = cell_width.saturating_sub(fixed).max(6);
let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, bar_width);
let name = truncate_and_pad(def.name, name_width);
let mut spans: Vec<Span> = vec![
Span::styled(prefix.to_string(), Style::default().fg(label_color)),
Span::styled(name, Style::default().fg(label_color)),
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
Span::styled(u_bar, Style::default().fg(colors.accent())),
Span::styled(e_bar, Style::default().fg(colors.text_pending())),
Span::styled(
format!(" {count}"),
Style::default().fg(colors.text_pending()),
),
];
let used = prefix.len() + name_width + bar_width + 1 + count.len();
if cell_width > used {
spans.push(Span::raw(" ".repeat(cell_width - used)));
}
spans
}
fn truncate_and_pad(name: &str, width: usize) -> String {
let mut text: String = name.chars().take(width).collect();
let len = text.chars().count();
if len < width {
text.push_str(&" ".repeat(width - len));
}
text
}
fn compact_dual_bar_parts( fn compact_dual_bar_parts(
mastered: usize, mastered: usize,
unlocked: usize, unlocked: usize,
@@ -143,3 +234,31 @@ fn compact_dual_bar_parts(
"\u{2591}".repeat(empty_cells), "\u{2591}".repeat(empty_cells),
) )
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrapped_rows_wraps_when_needed() {
assert_eq!(wrapped_branch_rows(120, 6), 2);
assert_eq!(wrapped_branch_rows(70, 6), 3);
assert_eq!(wrapped_branch_rows(50, 3), 3);
assert_eq!(wrapped_branch_rows(120, 0), 0);
}
#[test]
fn renders_branch_rows_when_height_is_two() {
assert!(should_render_branch_rows(2, 6));
assert!(!should_render_branch_rows(1, 6));
assert!(!should_render_branch_rows(2, 0));
}
#[test]
fn overall_separator_only_when_space_available() {
assert!(should_insert_overall_separator(1, 3));
assert!(should_insert_overall_separator(2, 4));
assert!(!should_insert_overall_separator(1, 2));
assert!(!should_insert_overall_separator(0, 4));
}
}

View File

@@ -1,3 +1,4 @@
use chrono::{Datelike, Utc};
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style}; use ratatui::style::{Modifier, Style};
@@ -50,6 +51,7 @@ pub struct StatsDashboard<'a> {
pub overall_total: usize, pub overall_total: usize,
pub theme: &'a Theme, pub theme: &'a Theme,
pub history_selected: usize, pub history_selected: usize,
pub history_scroll: usize,
pub history_confirm_delete: bool, pub history_confirm_delete: bool,
pub keyboard_model: &'a KeyboardModel, pub keyboard_model: &'a KeyboardModel,
pub ngram_data: Option<&'a NgramTabData>, pub ngram_data: Option<&'a NgramTabData>,
@@ -66,6 +68,7 @@ impl<'a> StatsDashboard<'a> {
overall_total: usize, overall_total: usize,
theme: &'a Theme, theme: &'a Theme,
history_selected: usize, history_selected: usize,
history_scroll: usize,
history_confirm_delete: bool, history_confirm_delete: bool,
keyboard_model: &'a KeyboardModel, keyboard_model: &'a KeyboardModel,
ngram_data: Option<&'a NgramTabData>, ngram_data: Option<&'a NgramTabData>,
@@ -80,6 +83,7 @@ impl<'a> StatsDashboard<'a> {
overall_total, overall_total,
theme, theme,
history_selected, history_selected,
history_scroll,
history_confirm_delete, history_confirm_delete,
keyboard_model, keyboard_model,
ngram_data, ngram_data,
@@ -108,56 +112,39 @@ impl Widget for StatsDashboard<'_> {
} }
// Tab header — width-aware wrapping // Tab header — width-aware wrapping
let tab_labels = [
"[1] Dashboard",
"[2] History",
"[3] Activity",
"[4] Accuracy",
"[5] Timing",
"[6] N-grams",
];
let tab_separator = " ";
let width = inner.width as usize; let width = inner.width as usize;
let mut tab_lines: Vec<Line> = Vec::new(); let mut tab_lines: Vec<Line> = Vec::new();
{ let mut current_spans: Vec<Span> = Vec::new();
let mut current_spans: Vec<Span> = Vec::new(); let mut current_width: usize = 0;
let mut current_width: usize = 0; for (i, &label) in TAB_LABELS.iter().enumerate() {
for (i, &label) in tab_labels.iter().enumerate() { let styled_label = format!(" {label} ");
let styled_label = format!(" {label} "); let item_width = styled_label.chars().count() + TAB_SEPARATOR.len();
let item_width = styled_label.chars().count() + tab_separator.len(); if current_width > 0 && current_width + item_width > width {
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)); 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; let tab_line_count = tab_lines.len().max(1) as u16;
// Footer — width-aware wrapping // Footer — width-aware wrapping
let footer_hints: Vec<&str> = if self.active_tab == 1 { let footer_hints: Vec<&str> = if self.active_tab == 1 {
vec![ FOOTER_HINTS_HISTORY.to_vec()
"[ESC] Back",
"[Tab] Next tab",
"[1-6] Switch tab",
"[j/k] Navigate",
"[x] Delete",
]
} else { } else {
vec!["[ESC] Back", "[Tab] Next tab", "[1-6] Switch tab"] FOOTER_HINTS_DEFAULT.to_vec()
}; };
let footer_lines_vec = pack_hint_lines(&footer_hints, width); let footer_lines_vec = pack_hint_lines(&footer_hints, width);
let footer_line_count = footer_lines_vec.len().max(1) as u16; let footer_line_count = footer_lines_vec.len().max(1) as u16;
@@ -658,7 +645,7 @@ impl StatsDashboard<'_> {
table_block.render(area, buf); table_block.render(area, buf);
let header = Line::from(vec![Span::styled( let header = Line::from(vec![Span::styled(
" # WPM Raw Acc% Time Date Mode Ranked Partial", " # WPM Raw Acc% Time Date/Time Mode Ranked Partial",
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -672,14 +659,19 @@ impl StatsDashboard<'_> {
)), )),
]; ];
let recent: Vec<&DrillResult> = self.history.iter().rev().take(20).collect(); let visible_rows = history_visible_rows(table_inner);
let total = self.history.len(); 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 (i, result) in recent.iter().enumerate() { for display_idx in scroll..end {
let idx = total - i; let result = &self.history[total - 1 - display_idx];
let idx = total - display_idx;
let raw_wpm = result.cpm / 5.0; let raw_wpm = result.cpm / 5.0;
let time_str = format!("{:.1}s", result.elapsed_secs); let time_str = format!("{:.1}s", result.elapsed_secs);
let date_str = result.timestamp.format("%m/%d %H:%M").to_string(); let date_str = format_history_timestamp(result.timestamp, current_year);
let idx_str = format!("{idx:>3}"); let idx_str = format!("{idx:>3}");
let wpm_str = format!("{:>6.0}", result.wpm); let wpm_str = format!("{:>6.0}", result.wpm);
@@ -701,7 +693,7 @@ impl StatsDashboard<'_> {
}; };
let partial_str = format!("{:>6.0}%", partial_pct); let partial_str = format!("{:>6.0}%", partial_pct);
let row = format!( let row = format!(
" {wpm_indicator}{idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str} {mode:<9} {rank_str:<6} {partial_str:>7}", " {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, mode = result.drill_mode,
); );
@@ -713,7 +705,7 @@ impl StatsDashboard<'_> {
colors.error() colors.error()
}; };
let is_selected = i == self.history_selected; let is_selected = display_idx == self.history_selected;
let style = if is_selected { let style = if is_selected {
Style::default().fg(acc_color).bg(colors.accent_dim()) Style::default().fg(acc_color).bg(colors.accent_dim())
} else if result.partial { } else if result.partial {
@@ -1662,6 +1654,65 @@ impl StatsDashboard<'_> {
} }
} }
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 { fn accuracy_color(accuracy: f64, colors: &crate::ui::theme::ThemeColors) -> ratatui::style::Color {
if accuracy <= 0.0 { if accuracy <= 0.0 {
colors.text_pending() colors.text_pending()
@@ -1924,6 +1975,7 @@ fn ngram_panel_layout(area: Rect) -> (bool, u16) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::TimeZone;
#[test] #[test]
fn narrow_short_terminal_shows_only_error_panel() { fn narrow_short_terminal_shows_only_error_panel() {
@@ -1975,4 +2027,40 @@ mod tests {
let (wide, _) = ngram_panel_layout(area); let (wide, _) = ngram_panel_layout(area);
assert!(wide, "60 cols should be wide"); 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}"
);
}
} }