Various UI fixes, better capital letter injection, paginated history
This commit is contained in:
92
src/app.rs
92
src/app.rs
@@ -222,6 +222,7 @@ pub struct App {
|
||||
pub depressed_keys: HashSet<char>,
|
||||
pub last_key_time: Option<Instant>,
|
||||
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<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)]
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +220,10 @@ fn lowercase_keys(count: usize) -> Vec<char> {
|
||||
|
||||
/// Base date for all profiles.
|
||||
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.
|
||||
|
||||
@@ -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<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)]
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
78
src/main.rs
78
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<Constraint> = 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};
|
||||
|
||||
|
||||
@@ -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<Span> = 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<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(
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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() {
|
||||
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 {
|
||||
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<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()
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user