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 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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::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};
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,23 +112,13 @@ 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));
|
tab_lines.push(Line::from(current_spans));
|
||||||
current_spans = Vec::new();
|
current_spans = Vec::new();
|
||||||
@@ -138,26 +132,19 @@ impl Widget for StatsDashboard<'_> {
|
|||||||
Style::default().fg(colors.text_pending())
|
Style::default().fg(colors.text_pending())
|
||||||
};
|
};
|
||||||
current_spans.push(Span::styled(styled_label, style));
|
current_spans.push(Span::styled(styled_label, style));
|
||||||
current_spans.push(Span::raw(tab_separator));
|
current_spans.push(Span::raw(TAB_SEPARATOR));
|
||||||
current_width += item_width;
|
current_width += item_width;
|
||||||
}
|
}
|
||||||
if !current_spans.is_empty() {
|
if !current_spans.is_empty() {
|
||||||
tab_lines.push(Line::from(current_spans));
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user