From b37dc72b45b2dd8c03f051165e7223eb3fd04e1d Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Sat, 28 Feb 2026 05:07:33 +0000 Subject: [PATCH] Various UI fixes, better capital letter injection, paginated history --- src/app.rs | 92 ++++++++++- src/bin/generate_test_profiles.rs | 5 +- src/generator/capitalize.rs | 57 +++++++ src/main.rs | 78 +++++++-- src/ui/components/branch_progress_list.rs | 183 ++++++++++++++++++---- src/ui/components/stats_dashboard.rs | 182 +++++++++++++++------ 6 files changed, 499 insertions(+), 98 deletions(-) diff --git a/src/app.rs b/src/app.rs index aad898d..2703568 100644 --- a/src/app.rs +++ b/src/app.rs @@ -222,6 +222,7 @@ pub struct App { pub depressed_keys: HashSet, pub last_key_time: Option, pub history_selected: usize, + pub history_scroll: usize, pub history_confirm_delete: bool, pub skill_tree_selected: usize, pub skill_tree_detail_scroll: usize, @@ -373,6 +374,7 @@ impl App { depressed_keys: HashSet::new(), last_key_time: None, history_selected: 0, + history_scroll: 0, history_confirm_delete: false, skill_tree_selected: 0, skill_tree_detail_scroll: 0, @@ -719,8 +721,9 @@ impl App { .filter(|ch| ch.is_ascii_lowercase() || *ch == ' ') .collect(); let filter = CharFilter::new(lowercase_keys); - // Only pass focused to phonetic generator if it's a lowercase letter - let lowercase_focused = focused_char.filter(|ch| ch.is_ascii_lowercase()); + // Feed uppercase focus as lowercase so capitals drills bias base word content + // 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 dict = Dictionary::load(); let rng = SmallRng::from_rng(&mut self.rng).unwrap(); @@ -1417,6 +1420,7 @@ impl App { self.clear_post_drill_input_lock(); self.stats_tab = 0; self.history_selected = 0; + self.history_scroll = 0; self.history_confirm_delete = false; self.screen = AppScreen::StatsDashboard; } @@ -1431,16 +1435,20 @@ impl App { self.rebuild_from_history(); self.save_data(); - // Clamp selection to visible range (max 20 visible rows) + // Clamp selection to full history range if !self.drill_history.is_empty() { - let max_visible = self.drill_history.len().min(20) - 1; - self.history_selected = self.history_selected.min(max_visible); + let max_idx = self.drill_history.len() - 1; + self.history_selected = self.history_selected.min(max_idx); + self.history_scroll = self.history_scroll.min(self.history_selected); } else { self.history_selected = 0; + self.history_scroll = 0; } } pub fn rebuild_from_history(&mut self) { + let previous_progress = self.profile.skill_tree.clone(); + // Reset all derived state self.key_stats = KeyStatsStore::default(); 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(); // 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). fn insert_line_breaks(text: &str) -> String { let mut result = String::with_capacity(text.len()); @@ -2182,6 +2218,18 @@ fn insert_line_breaks(text: &str) -> String { result } +fn lowercase_generation_focus(focused: Option) -> Option { + 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)] impl App { pub fn new_test() -> Self { @@ -2215,6 +2263,7 @@ impl App { depressed_keys: HashSet::new(), last_key_time: None, history_selected: 0, + history_scroll: 0, history_confirm_delete: false, skill_tree_selected: 0, skill_tree_detail_scroll: 0, @@ -2476,4 +2525,37 @@ mod tests { "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); + } } diff --git a/src/bin/generate_test_profiles.rs b/src/bin/generate_test_profiles.rs index 0a3ec81..6e6a4f5 100644 --- a/src/bin/generate_test_profiles.rs +++ b/src/bin/generate_test_profiles.rs @@ -220,7 +220,10 @@ fn lowercase_keys(count: usize) -> Vec { /// Base date for all profiles. fn base_date() -> DateTime { - 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. diff --git a/src/generator/capitalize.rs b/src/generator/capitalize.rs index 9dafad4..0f86e99 100644 --- a/src/generator/capitalize.rs +++ b/src/generator/capitalize.rs @@ -64,9 +64,54 @@ pub fn apply_capitalization( 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 } +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 = 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)] mod tests { use super::*; @@ -125,4 +170,16 @@ mod tests { "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}" + ); + } } diff --git a/src/main.rs b/src/main.rs index 3e83ef7..c452906 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,7 +40,9 @@ use ui::components::dashboard::Dashboard; use ui::layout::{pack_hint_lines, wrapped_line_count}; use ui::components::keyboard_diagram::KeyboardDiagram; 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::typing_area::TypingArea; use ui::layout::AppLayout; @@ -450,16 +452,30 @@ fn handle_stats_key(app: &mut App, key: KeyEvent) { // History tab has row navigation if app.stats_tab == 1 { + let page_size = current_history_page_size(); match key.code { KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), KeyCode::Char('j') | KeyCode::Down => { if !app.drill_history.is_empty() { - let max_visible = app.drill_history.len().min(20) - 1; - app.history_selected = (app.history_selected + 1).min(max_visible); + let max_idx = app.drill_history.len() - 1; + app.history_selected = (app.history_selected + 1).min(max_idx); + keep_history_selection_visible(app, page_size); } } KeyCode::Char('k') | KeyCode::Up => { 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 => { if !app.drill_history.is_empty() { @@ -1234,16 +1250,6 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) { }) .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 { if tier.compact_keyboard() { 6 // 3 rows + 2 border + 1 modifier space @@ -1254,6 +1260,35 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) { 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 = vec![Constraint::Min(5)]; if progress_height > 0 { 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.theme, app.history_selected, + app.history_scroll, app.history_confirm_delete, &app.keyboard_model, ngram_data.as_ref(), @@ -2530,6 +2566,22 @@ fn render_stats(frame: &mut ratatui::Frame, app: &App) { 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 { use engine::ngram_stats::{self, select_focus}; diff --git a/src/ui/components/branch_progress_list.rs b/src/ui/components/branch_progress_list.rs index da2a9df..777847f 100644 --- a/src/ui/components/branch_progress_list.rs +++ b/src/ui/components/branch_progress_list.rs @@ -16,6 +16,28 @@ pub struct BranchProgressList<'a> { 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<'_> { fn render(self, area: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; @@ -26,43 +48,40 @@ impl Widget for BranchProgressList<'_> { 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 { - 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) { break; } - let def = get_branch_definition(branch_id); - let total = SkillTree::branch_total_keys(branch_id); - let unlocked = self.skill_tree.branch_unlocked_count(branch_id); - let mastered = self - .skill_tree - .branch_confident_keys(branch_id, self.key_stats); - let is_active = drill_branch == Some(branch_id); - let prefix = if is_active { - " \u{25b6} " - } else { - " \u{00b7} " - }; - let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12); - let name = format!("{:<14}", def.name); - let label_color = if is_active { - colors.accent() - } else { - colors.text_pending() - }; - lines.push(Line::from(vec![ - Span::styled(prefix, Style::default().fg(label_color)), - Span::styled(name, Style::default().fg(label_color)), - Span::styled(m_bar, Style::default().fg(colors.text_correct())), - Span::styled(u_bar, Style::default().fg(colors.accent())), - Span::styled(e_bar, Style::default().fg(colors.text_pending())), - Span::styled( - format!(" {unlocked}/{total}"), - Style::default().fg(colors.text_pending()), - ), - ])); + + let mut spans: Vec = Vec::new(); + for col in 0..columns { + let idx = row * columns + col; + if idx >= self.active_branches.len() { + break; + } + if col > 0 { + spans.push(Span::raw(" ".repeat(BRANCH_CELL_GUTTER))); + } + let branch_id = self.active_branches[idx]; + spans.extend(render_branch_cell( + branch_id, + drill_branch == Some(branch_id), + cell_width, + self.skill_tree, + self.key_stats, + self.theme, + )); + } + lines.push(Line::from(spans)); } } else if let Some(branch_id) = drill_branch { let def = get_branch_definition(branch_id); @@ -89,6 +108,9 @@ impl Widget for BranchProgressList<'_> { // Overall line 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 unlocked = self.skill_tree.total_unlocked_count(); 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> { + 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 = 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( mastered: usize, unlocked: usize, @@ -143,3 +234,31 @@ fn compact_dual_bar_parts( "\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)); + } +} diff --git a/src/ui/components/stats_dashboard.rs b/src/ui/components/stats_dashboard.rs index 8569fbd..a194580 100644 --- a/src/ui/components/stats_dashboard.rs +++ b/src/ui/components/stats_dashboard.rs @@ -1,3 +1,4 @@ +use chrono::{Datelike, Utc}; use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; @@ -50,6 +51,7 @@ pub struct StatsDashboard<'a> { 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>, @@ -66,6 +68,7 @@ impl<'a> StatsDashboard<'a> { 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>, @@ -80,6 +83,7 @@ impl<'a> StatsDashboard<'a> { overall_total, theme, history_selected, + history_scroll, history_confirm_delete, keyboard_model, ngram_data, @@ -108,56 +112,39 @@ impl Widget for StatsDashboard<'_> { } // 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 mut tab_lines: Vec = Vec::new(); - { - let mut current_spans: Vec = 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() { + let mut current_spans: Vec = 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 { - vec![ - "[ESC] Back", - "[Tab] Next tab", - "[1-6] Switch tab", - "[j/k] Navigate", - "[x] Delete", - ] + FOOTER_HINTS_HISTORY.to_vec() } 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_line_count = footer_lines_vec.len().max(1) as u16; @@ -658,7 +645,7 @@ impl StatsDashboard<'_> { table_block.render(area, buf); 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() .fg(colors.accent()) .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 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() { - let idx = total - i; + 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 = 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 wpm_str = format!("{:>6.0}", result.wpm); @@ -701,7 +693,7 @@ impl StatsDashboard<'_> { }; 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} {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, ); @@ -713,7 +705,7 @@ impl StatsDashboard<'_> { colors.error() }; - let is_selected = i == self.history_selected; + 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 { @@ -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, 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() @@ -1924,6 +1975,7 @@ fn ngram_panel_layout(area: Rect) -> (bool, u16) { #[cfg(test)] mod tests { use super::*; + use chrono::TimeZone; #[test] fn narrow_short_terminal_shows_only_error_panel() { @@ -1975,4 +2027,40 @@ mod tests { 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}" + ); + } }