Skill Tree page UI tweaks and improvements

This commit is contained in:
2026-02-28 06:03:20 +00:00
parent b37dc72b45
commit 8e4f9bf064
11 changed files with 627 additions and 157 deletions

View File

@@ -226,6 +226,7 @@ pub struct App {
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,
pub skill_tree_confirm_unlock: Option<BranchId>,
pub drill_source_info: Option<String>, pub drill_source_info: Option<String>,
pub code_language_selected: usize, pub code_language_selected: usize,
pub code_language_scroll: usize, pub code_language_scroll: usize,
@@ -378,6 +379,7 @@ impl App {
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,
skill_tree_confirm_unlock: None,
drill_source_info: None, drill_source_info: None,
code_language_selected: 0, code_language_selected: 0,
code_language_scroll: 0, code_language_scroll: 0,
@@ -727,10 +729,13 @@ impl App {
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();
let cross_drill_history: HashSet<String> = let cross_drill_history: HashSet<String> = self
self.adaptive_word_history.iter().flatten().cloned().collect(); .adaptive_word_history
let mut generator = .iter()
PhoneticGenerator::new(table, dict, rng, cross_drill_history); .flatten()
.cloned()
.collect();
let mut generator = PhoneticGenerator::new(table, dict, rng, cross_drill_history);
let mut text = let mut text =
generator.generate(&filter, lowercase_focused, focused_bigram, word_count); generator.generate(&filter, lowercase_focused, focused_bigram, word_count);
@@ -1523,14 +1528,19 @@ impl App {
pub fn go_to_skill_tree(&mut self) { pub fn go_to_skill_tree(&mut self) {
self.skill_tree_selected = 0; self.skill_tree_selected = 0;
self.skill_tree_detail_scroll = 0; self.skill_tree_detail_scroll = 0;
self.skill_tree_confirm_unlock = None;
self.screen = AppScreen::SkillTree; self.screen = AppScreen::SkillTree;
} }
pub fn start_branch_drill(&mut self, branch_id: BranchId) { pub fn unlock_branch(&mut self, branch_id: BranchId) {
// Start the branch if it's Available // Start the branch if it's Available.
self.skill_tree.start_branch(branch_id); self.skill_tree.start_branch(branch_id);
self.profile.skill_tree = self.skill_tree.progress.clone(); self.profile.skill_tree = self.skill_tree.progress.clone();
self.save_data(); self.save_data();
}
pub fn start_branch_drill(&mut self, branch_id: BranchId) {
self.unlock_branch(branch_id);
// Use adaptive mode with branch-specific scope // Use adaptive mode with branch-specific scope
let old_mode = self.drill_mode; let old_mode = self.drill_mode;
@@ -2267,6 +2277,7 @@ impl App {
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,
skill_tree_confirm_unlock: None,
drill_source_info: None, drill_source_info: None,
code_language_selected: 0, code_language_selected: 0,
code_language_scroll: 0, code_language_scroll: 0,

View File

@@ -8,11 +8,11 @@ use rand::{Rng, SeedableRng};
use keydr::config::Config; use keydr::config::Config;
use keydr::engine::key_stats::{KeyStat, KeyStatsStore}; use keydr::engine::key_stats::{KeyStat, KeyStatsStore};
use keydr::engine::skill_tree::{ use keydr::engine::skill_tree::{
BranchId, BranchProgress, BranchStatus, SkillTreeProgress, ALL_BRANCHES, ALL_BRANCHES, BranchId, BranchProgress, BranchStatus, SkillTreeProgress,
}; };
use keydr::session::result::{DrillResult, KeyTime}; use keydr::session::result::{DrillResult, KeyTime};
use keydr::store::schema::{ use keydr::store::schema::{
DrillHistoryData, ExportData, KeyStatsData, ProfileData, EXPORT_VERSION, DrillHistoryData, EXPORT_VERSION, ExportData, KeyStatsData, ProfileData,
}; };
const SCHEMA_VERSION: u32 = 2; const SCHEMA_VERSION: u32 = 2;
@@ -62,8 +62,7 @@ fn make_key_stat(rng: &mut SmallRng, confidence: f64, sample_count: usize) -> Ke
/// Generate monotonic timestamps: base_date + day_offset days + drill_offset * 2min. /// Generate monotonic timestamps: base_date + day_offset days + drill_offset * 2min.
fn drill_timestamp(base: DateTime<Utc>, day: u32, drill_in_day: u32) -> DateTime<Utc> { fn drill_timestamp(base: DateTime<Utc>, day: u32, drill_in_day: u32) -> DateTime<Utc> {
base + chrono::Duration::days(day as i64) base + chrono::Duration::days(day as i64) + chrono::Duration::seconds(drill_in_day as i64 * 120)
+ chrono::Duration::seconds(drill_in_day as i64 * 120)
} }
/// Generate a DrillResult with deterministic per_key_times. /// Generate a DrillResult with deterministic per_key_times.
@@ -267,15 +266,16 @@ fn generate_drills(
} }
fn last_practice_date_from_drills(drills: &[DrillResult]) -> Option<String> { fn last_practice_date_from_drills(drills: &[DrillResult]) -> Option<String> {
drills.last().map(|d| d.timestamp.format("%Y-%m-%d").to_string()) drills
.last()
.map(|d| d.timestamp.format("%Y-%m-%d").to_string())
} }
// ── Profile Builders ───────────────────────────────────────────────────── // ── Profile Builders ─────────────────────────────────────────────────────
fn build_profile_01() -> ExportData { fn build_profile_01() -> ExportData {
let skill_tree = make_skill_tree_progress(vec![ let skill_tree =
(BranchId::Lowercase, BranchStatus::InProgress, 0), make_skill_tree_progress(vec![(BranchId::Lowercase, BranchStatus::InProgress, 0)]);
]);
make_export( make_export(
ProfileData { ProfileData {
@@ -295,9 +295,8 @@ fn build_profile_01() -> ExportData {
fn build_profile_02() -> ExportData { fn build_profile_02() -> ExportData {
// Lowercase InProgress level 4 => 6 + 4 = 10 keys: e,t,a,o,i,n,s,h,r,d // Lowercase InProgress level 4 => 6 + 4 = 10 keys: e,t,a,o,i,n,s,h,r,d
let skill_tree = make_skill_tree_progress(vec![ let skill_tree =
(BranchId::Lowercase, BranchStatus::InProgress, 4), make_skill_tree_progress(vec![(BranchId::Lowercase, BranchStatus::InProgress, 4)]);
]);
let all_keys = lowercase_keys(10); let all_keys = lowercase_keys(10);
let mastered_keys = &all_keys[..6]; // e,t,a,o,i,n let mastered_keys = &all_keys[..6]; // e,t,a,o,i,n
@@ -323,12 +322,11 @@ fn build_profile_02() -> ExportData {
} else { } else {
(base.confidence + rng.gen_range(-0.1..0.08)).clamp(0.15, 0.95) (base.confidence + rng.gen_range(-0.1..0.08)).clamp(0.15, 0.95)
}; };
let sample_count = ((base.sample_count as f64) * rng.gen_range(0.5..0.8)).round() as usize let sample_count =
+ 6; ((base.sample_count as f64) * rng.gen_range(0.5..0.8)).round() as usize + 6;
ranked_stats.stats.insert( ranked_stats
k, .stats
make_key_stat(&mut rng, conf, sample_count), .insert(k, make_key_stat(&mut rng, conf, sample_count));
);
} }
let drills = generate_drills( let drills = generate_drills(
@@ -359,9 +357,8 @@ fn build_profile_02() -> ExportData {
fn build_profile_03() -> ExportData { fn build_profile_03() -> ExportData {
// Lowercase InProgress level 12 => 6 + 12 = 18 keys through 'y' // Lowercase InProgress level 12 => 6 + 12 = 18 keys through 'y'
let skill_tree = make_skill_tree_progress(vec![ let skill_tree =
(BranchId::Lowercase, BranchStatus::InProgress, 12), make_skill_tree_progress(vec![(BranchId::Lowercase, BranchStatus::InProgress, 12)]);
]);
let all_keys = lowercase_keys(18); let all_keys = lowercase_keys(18);
let mastered_keys = &all_keys[..14]; let mastered_keys = &all_keys[..14];
@@ -389,10 +386,9 @@ fn build_profile_03() -> ExportData {
}; };
let sample_count = let sample_count =
((base.sample_count as f64) * rng.gen_range(0.52..0.82)).round() as usize + 8; ((base.sample_count as f64) * rng.gen_range(0.52..0.82)).round() as usize + 8;
ranked_stats.stats.insert( ranked_stats
k, .stats
make_key_stat(&mut rng, conf, sample_count), .insert(k, make_key_stat(&mut rng, conf, sample_count));
);
} }
let drills = generate_drills( let drills = generate_drills(
@@ -445,10 +441,9 @@ fn build_profile_04() -> ExportData {
let conf = (base.confidence - rng.gen_range(0.0..0.2)).max(1.0); let conf = (base.confidence - rng.gen_range(0.0..0.2)).max(1.0);
let sample_count = let sample_count =
((base.sample_count as f64) * rng.gen_range(0.55..0.85)).round() as usize + 10; ((base.sample_count as f64) * rng.gen_range(0.55..0.85)).round() as usize + 10;
ranked_stats.stats.insert( ranked_stats
k, .stats
make_key_stat(&mut rng, conf, sample_count), .insert(k, make_key_stat(&mut rng, conf, sample_count));
);
} }
let drills = generate_drills( let drills = generate_drills(
@@ -540,7 +535,9 @@ fn build_profile_05() -> ExportData {
// Ranked key stats: cover all keys used in ranked drills (all_unlocked) // Ranked key stats: cover all keys used in ranked drills (all_unlocked)
let mut ranked_stats = KeyStatsStore::default(); let mut ranked_stats = KeyStatsStore::default();
for &k in &all_unlocked { for &k in &all_unlocked {
ranked_stats.stats.insert(k, make_key_stat(&mut rng, 1.1, 20)); ranked_stats
.stats
.insert(k, make_key_stat(&mut rng, 1.1, 20));
} }
// level ~7: score ~5000 // level ~7: score ~5000
@@ -632,7 +629,9 @@ fn build_profile_06() -> ExportData {
// Ranked key stats: cover all keys used in ranked drills (all_unlocked) // Ranked key stats: cover all keys used in ranked drills (all_unlocked)
let mut ranked_stats = KeyStatsStore::default(); let mut ranked_stats = KeyStatsStore::default();
for &k in &all_unlocked { for &k in &all_unlocked {
ranked_stats.stats.insert(k, make_key_stat(&mut rng, 1.1, 30)); ranked_stats
.stats
.insert(k, make_key_stat(&mut rng, 1.1, 30));
} }
// level ~12: score ~15000 // level ~12: score ~15000
@@ -711,7 +710,9 @@ fn build_profile_07() -> ExportData {
// Full ranked stats // Full ranked stats
let mut ranked_stats = KeyStatsStore::default(); let mut ranked_stats = KeyStatsStore::default();
for &k in &all_keys { for &k in &all_keys {
ranked_stats.stats.insert(k, make_key_stat(&mut rng, 1.4, 80)); ranked_stats
.stats
.insert(k, make_key_stat(&mut rng, 1.4, 80));
} }
// level ~18: score ~35000 // level ~18: score ~35000

View File

@@ -90,8 +90,8 @@ fn ensure_min_focused_occurrences(text: &str, focused_upper: char, min_count: us
if chars[i] != focused_lower { if chars[i] != focused_lower {
continue; continue;
} }
let is_word_start = i == 0 let is_word_start =
|| matches!(chars.get(i.saturating_sub(1)), Some(' ' | '\n' | '\t')); i == 0 || matches!(chars.get(i.saturating_sub(1)), Some(' ' | '\n' | '\t'));
if is_word_start { if is_word_start {
chars[i] = focused_upper; chars[i] = focused_upper;
count += 1; count += 1;

View File

@@ -366,8 +366,7 @@ impl TextGenerator for PhoneticGenerator {
} else if pool_size >= FULL_DICT_THRESHOLD { } else if pool_size >= FULL_DICT_THRESHOLD {
1.0 1.0
} else { } else {
(pool_size - MIN_REAL_WORDS) as f64 (pool_size - MIN_REAL_WORDS) as f64 / (FULL_DICT_THRESHOLD - MIN_REAL_WORDS) as f64
/ (FULL_DICT_THRESHOLD - MIN_REAL_WORDS) as f64
}; };
// Scaled within-drill dedup window based on dictionary pool size // Scaled within-drill dedup window based on dictionary pool size
@@ -379,8 +378,7 @@ impl TextGenerator for PhoneticGenerator {
// Cross-drill history accept probability (computed once) // Cross-drill history accept probability (computed once)
let cross_drill_accept_prob = if pool_size > 0 { let cross_drill_accept_prob = if pool_size > 0 {
let pool_set: HashSet<&str> = let pool_set: HashSet<&str> = matching_words.iter().map(|s| s.as_str()).collect();
matching_words.iter().map(|s| s.as_str()).collect();
let history_in_pool = self let history_in_pool = self
.cross_drill_history .cross_drill_history
.iter() .iter()
@@ -474,8 +472,12 @@ mod tests {
.filter(|w| w.contains('k')) .filter(|w| w.contains('k'))
.count(); .count();
let mut baseline_gen = let mut baseline_gen = PhoneticGenerator::new(
PhoneticGenerator::new(table, Dictionary::load(), SmallRng::seed_from_u64(42), HashSet::new()); table,
Dictionary::load(),
SmallRng::seed_from_u64(42),
HashSet::new(),
);
let baseline_text = baseline_gen.generate(&filter, None, None, 1200); let baseline_text = baseline_gen.generate(&filter, None, None, 1200);
let baseline_count = baseline_text let baseline_count = baseline_text
.split_whitespace() .split_whitespace()
@@ -506,8 +508,12 @@ mod tests {
.filter(|w| w.contains("th")) .filter(|w| w.contains("th"))
.count(); .count();
let mut baseline_gen = let mut baseline_gen = PhoneticGenerator::new(
PhoneticGenerator::new(table, Dictionary::load(), SmallRng::seed_from_u64(42), HashSet::new()); table,
Dictionary::load(),
SmallRng::seed_from_u64(42),
HashSet::new(),
);
let baseline_text = baseline_gen.generate(&filter, None, None, 1200); let baseline_text = baseline_gen.generate(&filter, None, None, 1200);
let baseline_count = baseline_text let baseline_count = baseline_text
.split_whitespace() .split_whitespace()
@@ -526,8 +532,12 @@ mod tests {
let table = TransitionTable::build_from_words(&dictionary.words_list()); let table = TransitionTable::build_from_words(&dictionary.words_list());
let filter = CharFilter::new(('a'..='z').collect()); let filter = CharFilter::new(('a'..='z').collect());
let mut generator = let mut generator = PhoneticGenerator::new(
PhoneticGenerator::new(table, Dictionary::load(), SmallRng::seed_from_u64(42), HashSet::new()); table,
Dictionary::load(),
SmallRng::seed_from_u64(42),
HashSet::new(),
);
let text = generator.generate(&filter, Some('k'), Some(['t', 'h']), 200); let text = generator.generate(&filter, Some('k'), Some(['t', 'h']), 200);
let words: Vec<&str> = text.split_whitespace().collect(); let words: Vec<&str> = text.split_whitespace().collect();
@@ -580,8 +590,10 @@ mod tests {
HashSet::new(), HashSet::new(),
); );
let text2_no_hist = gen2_no_hist.generate(&filter, Some('k'), None, word_count); let text2_no_hist = gen2_no_hist.generate(&filter, Some('k'), None, word_count);
let words2_no_hist: HashSet<String> = let words2_no_hist: HashSet<String> = text2_no_hist
text2_no_hist.split_whitespace().map(|w| w.to_string()).collect(); .split_whitespace()
.map(|w| w.to_string())
.collect();
let baseline_intersection = words1.intersection(&words2_no_hist).count(); let baseline_intersection = words1.intersection(&words2_no_hist).count();
let baseline_union = words1.union(&words2_no_hist).count(); let baseline_union = words1.union(&words2_no_hist).count();
let baseline_jaccard = baseline_intersection as f64 / baseline_union as f64; let baseline_jaccard = baseline_intersection as f64 / baseline_union as f64;
@@ -594,8 +606,10 @@ mod tests {
words1.clone(), words1.clone(),
); );
let text2_with_hist = gen2_with_hist.generate(&filter, Some('k'), None, word_count); let text2_with_hist = gen2_with_hist.generate(&filter, Some('k'), None, word_count);
let words2_with_hist: HashSet<String> = let words2_with_hist: HashSet<String> = text2_with_hist
text2_with_hist.split_whitespace().map(|w| w.to_string()).collect(); .split_whitespace()
.map(|w| w.to_string())
.collect();
let hist_intersection = words1.intersection(&words2_with_hist).count(); let hist_intersection = words1.intersection(&words2_with_hist).count();
let hist_union = words1.union(&words2_with_hist).count(); let hist_union = words1.union(&words2_with_hist).count();
let hist_jaccard = hist_intersection as f64 / hist_union as f64; let hist_jaccard = hist_intersection as f64 / hist_union as f64;
@@ -758,7 +772,11 @@ mod tests {
.collect(); .collect();
// Create history containing most of the pool words (up to 8) // Create history containing most of the pool words (up to 8)
let history: HashSet<String> = matching.iter().take(8.min(matching.len())).cloned().collect(); let history: HashSet<String> = matching
.iter()
.take(8.min(matching.len()))
.cloned()
.collect();
let mut generator = PhoneticGenerator::new( let mut generator = PhoneticGenerator::new(
table, table,
@@ -773,10 +791,7 @@ mod tests {
assert!(!words.is_empty(), "Should generate non-empty output"); assert!(!words.is_empty(), "Should generate non-empty output");
// History words should still appear (suppression is soft, not hard exclusion) // History words should still appear (suppression is soft, not hard exclusion)
let history_words_in_output: usize = words let history_words_in_output: usize = words.iter().filter(|w| history.contains(**w)).count();
.iter()
.filter(|w| history.contains(**w))
.count();
// With soft suppression, at least some history words should appear // With soft suppression, at least some history words should appear
// (they're accepted with reduced probability, not blocked) // (they're accepted with reduced probability, not blocked)
assert!( assert!(

View File

@@ -29,7 +29,6 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget, Wrap}; use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
use app::{App, AppScreen, DrillMode, MilestoneKind, StatusKind}; use app::{App, AppScreen, DrillMode, MilestoneKind, StatusKind};
use ui::line_input::{InputResult, LineInput, PathField};
use engine::skill_tree::{DrillScope, find_key_branch}; use engine::skill_tree::{DrillScope, find_key_branch};
use event::{AppEvent, EventHandler}; use event::{AppEvent, EventHandler};
use generator::code_syntax::{code_language_options, is_language_cached, language_by_key}; use generator::code_syntax::{code_language_options, is_language_cached, language_by_key};
@@ -37,15 +36,19 @@ use generator::passage::{is_book_cached, passage_options};
use keyboard::display::key_display_name; use keyboard::display::key_display_name;
use keyboard::finger::Hand; use keyboard::finger::Hand;
use ui::components::dashboard::Dashboard; use ui::components::dashboard::Dashboard;
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_with_level_spacing, selectable_branches,
use_expanded_level_spacing, use_side_by_side_layout,
};
use ui::components::stats_dashboard::{ use ui::components::stats_dashboard::{
AnomalyBigramRow, NgramTabData, StatsDashboard, history_page_size_for_terminal, 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;
use ui::layout::{pack_hint_lines, wrapped_line_count};
use ui::line_input::{InputResult, LineInput, PathField};
#[derive(Parser)] #[derive(Parser)]
#[command( #[command(
@@ -971,6 +974,20 @@ fn handle_code_download_progress_key(app: &mut App, key: KeyEvent) {
fn handle_skill_tree_key(app: &mut App, key: KeyEvent) { fn handle_skill_tree_key(app: &mut App, key: KeyEvent) {
const DETAIL_SCROLL_STEP: usize = 10; const DETAIL_SCROLL_STEP: usize = 10;
if let Some(branch_id) = app.skill_tree_confirm_unlock {
match key.code {
KeyCode::Char('y') => {
app.unlock_branch(branch_id);
app.skill_tree_confirm_unlock = None;
}
KeyCode::Char('n') | KeyCode::Esc => {
app.skill_tree_confirm_unlock = None;
}
_ => {}
}
return;
}
let max_scroll = skill_tree_detail_max_scroll(app); let max_scroll = skill_tree_detail_max_scroll(app);
app.skill_tree_detail_scroll = app.skill_tree_detail_scroll.min(max_scroll); app.skill_tree_detail_scroll = app.skill_tree_detail_scroll.min(max_scroll);
let branches = selectable_branches(); let branches = selectable_branches();
@@ -1014,9 +1031,9 @@ fn handle_skill_tree_key(app: &mut App, key: KeyEvent) {
if app.skill_tree_selected < branches.len() { if app.skill_tree_selected < branches.len() {
let branch_id = branches[app.skill_tree_selected]; let branch_id = branches[app.skill_tree_selected];
let status = app.skill_tree.branch_status(branch_id).clone(); let status = app.skill_tree.branch_status(branch_id).clone();
if status == engine::skill_tree::BranchStatus::Available if status == engine::skill_tree::BranchStatus::Available {
|| status == engine::skill_tree::BranchStatus::InProgress app.skill_tree_confirm_unlock = Some(branch_id);
{ } else if status == engine::skill_tree::BranchStatus::InProgress {
app.start_branch_drill(branch_id); app.start_branch_drill(branch_id);
} }
} }
@@ -1040,21 +1057,83 @@ fn skill_tree_detail_max_scroll(app: &App) -> usize {
if branches.is_empty() { if branches.is_empty() {
return 0; return 0;
} }
let branch_list_height = branches.len() as u16 * 2 + 1;
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(branch_list_height.min(inner.height.saturating_sub(6))),
Constraint::Length(1),
Constraint::Min(4),
Constraint::Length(2),
])
.split(inner);
let detail_height = layout.get(2).map(|r| r.height as usize).unwrap_or(0);
let selected = app let selected = app
.skill_tree_selected .skill_tree_selected
.min(branches.len().saturating_sub(1)); .min(branches.len().saturating_sub(1));
let total_lines = detail_line_count(branches[selected]); let bp = app.skill_tree.branch_progress(branches[selected]);
let (footer_hints, footer_notice) = if *app.skill_tree.branch_status(branches[selected])
== engine::skill_tree::BranchStatus::Locked
{
(
vec![
"[↑↓/jk] Navigate",
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
"[q] Back",
],
Some("Complete a-z to unlock branches"),
)
} else if bp.status == engine::skill_tree::BranchStatus::Available {
(
vec![
"[Enter] Unlock",
"[↑↓/jk] Navigate",
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
"[q] Back",
],
None,
)
} else if bp.status == engine::skill_tree::BranchStatus::InProgress {
(
vec![
"[Enter] Start Drill",
"[↑↓/jk] Navigate",
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
"[q] Back",
],
None,
)
} else {
(
vec![
"[↑↓/jk] Navigate",
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
"[q] Back",
],
None,
)
};
let hint_lines = pack_hint_lines(&footer_hints, inner.width as usize);
let notice_lines = footer_notice
.map(|text| wrapped_line_count(text, inner.width as usize))
.unwrap_or(0);
let show_notice =
footer_notice.is_some() && (inner.height as usize >= hint_lines.len() + notice_lines + 8);
let footer_needed = hint_lines.len() + if show_notice { notice_lines } else { 0 } + 1;
let footer_height = footer_needed
.min(inner.height.saturating_sub(5) as usize)
.max(1) as u16;
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(4), Constraint::Length(footer_height)])
.split(inner);
let side_by_side = use_side_by_side_layout(inner.width);
let detail_height = if side_by_side {
layout.first().map(|r| r.height as usize).unwrap_or(0)
} else {
let branch_list_height = branches.len() as u16 * 2 + 1;
let main = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(branch_list_height.min(layout[0].height.saturating_sub(4))),
Constraint::Length(1),
Constraint::Min(3),
])
.split(layout[0]);
main.get(2).map(|r| r.height as usize).unwrap_or(0)
};
let expanded = use_expanded_level_spacing(detail_height as u16, branches[selected]);
let total_lines = detail_line_count_with_level_spacing(branches[selected], expanded);
total_lines.saturating_sub(detail_height) total_lines.saturating_sub(detail_height)
} }
@@ -2252,7 +2331,7 @@ mod review_tests {
#[test] #[test]
fn build_ngram_tab_data_maps_fields_correctly() { fn build_ngram_tab_data_maps_fields_correctly() {
use crate::engine::ngram_stats::{BigramKey, ANOMALY_STREAK_REQUIRED}; use crate::engine::ngram_stats::{ANOMALY_STREAK_REQUIRED, BigramKey};
let mut app = test_app(); let mut app = test_app();
@@ -2402,7 +2481,10 @@ mod review_tests {
); );
let after_cursor = app.drill.as_ref().unwrap().cursor; let after_cursor = app.drill.as_ref().unwrap().cursor;
assert_eq!(before_cursor, after_cursor, "Key should be blocked during input lock on Drill screen"); assert_eq!(
before_cursor, after_cursor,
"Key should be blocked during input lock on Drill screen"
);
assert_eq!(app.screen, AppScreen::Drill); assert_eq!(app.screen, AppScreen::Drill);
} }
@@ -2419,15 +2501,34 @@ mod review_tests {
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
); );
assert!(app.should_quit, "Ctrl+C should set should_quit even during input lock"); assert!(
app.should_quit,
"Ctrl+C should set should_quit even during input lock"
);
} }
/// Helper: render settings to a test buffer and return its text content. /// Helper: render settings to a test buffer and return its text content.
fn render_settings_to_string(app: &App) -> String { fn render_settings_to_string(app: &App) -> String {
let backend = ratatui::backend::TestBackend::new(80, 40); let backend = ratatui::backend::TestBackend::new(80, 40);
let mut terminal = Terminal::new(backend).unwrap(); let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|frame| render_settings(frame, app)).unwrap();
let buf = terminal.backend().buffer().clone();
let mut text = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
text.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
text.push('\n');
}
text
}
/// Helper: render skill tree to a test buffer and return its text content.
fn render_skill_tree_to_string_with_size(app: &App, width: u16, height: u16) -> String {
let backend = ratatui::backend::TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal terminal
.draw(|frame| render_settings(frame, app)) .draw(|frame| render_skill_tree(frame, app))
.unwrap(); .unwrap();
let buf = terminal.backend().buffer().clone(); let buf = terminal.backend().buffer().clone();
let mut text = String::new(); let mut text = String::new();
@@ -2440,6 +2541,10 @@ mod review_tests {
text text
} }
fn render_skill_tree_to_string(app: &App) -> String {
render_skill_tree_to_string_with_size(app, 120, 40)
}
#[test] #[test]
fn footer_shows_completion_error_and_clears_on_keystroke() { fn footer_shows_completion_error_and_clears_on_keystroke() {
let mut app = test_app(); let mut app = test_app();
@@ -2468,10 +2573,7 @@ mod review_tests {
); );
// Press a non-tab key to clear the error // Press a non-tab key to clear the error
handle_settings_key( handle_settings_key(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
&mut app,
KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
);
// Render again — error hint should be gone // Render again — error hint should be gone
let output_after = render_settings_to_string(&app); let output_after = render_settings_to_string(&app);
@@ -2500,6 +2602,152 @@ mod review_tests {
assert!(output_during.contains("[Esc] Cancel")); assert!(output_during.contains("[Esc] Cancel"));
assert!(output_during.contains("[Tab] Complete")); assert!(output_during.contains("[Tab] Complete"));
} }
#[test]
fn skill_tree_available_branch_enter_opens_unlock_confirm() {
let mut app = test_app();
app.screen = AppScreen::SkillTree;
app.skill_tree_confirm_unlock = None;
app.skill_tree
.branch_progress_mut(engine::skill_tree::BranchId::Capitals)
.status = engine::skill_tree::BranchStatus::Available;
app.skill_tree_selected = selectable_branches()
.iter()
.position(|id| *id == engine::skill_tree::BranchId::Capitals)
.unwrap();
handle_skill_tree_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
app.skill_tree_confirm_unlock,
Some(engine::skill_tree::BranchId::Capitals)
);
assert_eq!(
*app.skill_tree
.branch_status(engine::skill_tree::BranchId::Capitals),
engine::skill_tree::BranchStatus::Available
);
assert_eq!(app.screen, AppScreen::SkillTree);
}
#[test]
fn skill_tree_unlock_confirm_yes_unlocks_without_starting_drill() {
let mut app = test_app();
app.screen = AppScreen::SkillTree;
app.skill_tree_confirm_unlock = Some(engine::skill_tree::BranchId::Capitals);
app.skill_tree
.branch_progress_mut(engine::skill_tree::BranchId::Capitals)
.status = engine::skill_tree::BranchStatus::Available;
handle_skill_tree_key(
&mut app,
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE),
);
assert_eq!(app.skill_tree_confirm_unlock, None);
assert_eq!(
*app.skill_tree
.branch_status(engine::skill_tree::BranchId::Capitals),
engine::skill_tree::BranchStatus::InProgress
);
assert_eq!(app.screen, AppScreen::SkillTree);
}
#[test]
fn skill_tree_in_progress_branch_enter_starts_branch_drill() {
let mut app = test_app();
app.screen = AppScreen::SkillTree;
app.skill_tree
.branch_progress_mut(engine::skill_tree::BranchId::Capitals)
.status = engine::skill_tree::BranchStatus::InProgress;
app.skill_tree_selected = selectable_branches()
.iter()
.position(|id| *id == engine::skill_tree::BranchId::Capitals)
.unwrap();
handle_skill_tree_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.skill_tree_confirm_unlock, None);
assert_eq!(app.screen, AppScreen::Drill);
assert_eq!(
app.drill_scope,
DrillScope::Branch(engine::skill_tree::BranchId::Capitals)
);
}
#[test]
fn skill_tree_available_branch_footer_shows_unlock_hint() {
let mut app = test_app();
app.screen = AppScreen::SkillTree;
app.skill_tree
.branch_progress_mut(engine::skill_tree::BranchId::Capitals)
.status = engine::skill_tree::BranchStatus::Available;
app.skill_tree_selected = selectable_branches()
.iter()
.position(|id| *id == engine::skill_tree::BranchId::Capitals)
.unwrap();
let output = render_skill_tree_to_string(&app);
assert!(output.contains("[Enter] Unlock"));
}
#[test]
fn skill_tree_unlock_modal_shows_body_and_prompt_text() {
let mut app = test_app();
app.screen = AppScreen::SkillTree;
app.skill_tree_confirm_unlock = Some(engine::skill_tree::BranchId::Capitals);
let output = render_skill_tree_to_string(&app);
assert!(output.contains("default adaptive drill will mix in keys"));
assert!(output.contains("focus only on this branch"));
assert!(output.contains("from this branch in the Skill Tree."));
assert!(output.contains("Proceed? (y/n)"));
}
#[test]
fn skill_tree_unlock_modal_keeps_full_second_sentence_on_smaller_terminal() {
let mut app = test_app();
app.screen = AppScreen::SkillTree;
app.skill_tree_confirm_unlock = Some(engine::skill_tree::BranchId::Capitals);
let output = render_skill_tree_to_string_with_size(&app, 90, 24);
assert!(output.contains("focus only on this branch"));
assert!(output.contains("from this branch in the Skill Tree."));
assert!(output.contains("Proceed? (y/n)"));
}
#[test]
fn skill_tree_layout_switches_with_width() {
assert!(!use_side_by_side_layout(99));
assert!(use_side_by_side_layout(100));
}
#[test]
fn skill_tree_expanded_branch_spacing_threshold() {
// 6 branches => base=13 lines, inter-branch spacing needs +5, separator padding needs +2.
assert_eq!(
crate::ui::components::skill_tree::branch_list_spacing_flags(17, 6),
(false, false)
);
assert_eq!(
crate::ui::components::skill_tree::branch_list_spacing_flags(18, 6),
(true, false)
);
assert_eq!(
crate::ui::components::skill_tree::branch_list_spacing_flags(20, 6),
(true, true)
);
}
#[test]
fn skill_tree_expanded_level_spacing_threshold() {
use crate::engine::skill_tree::BranchId;
let id = BranchId::Capitals;
let base = crate::ui::components::skill_tree::detail_line_count(id) as u16;
// Capitals has 3 levels, so expanded spacing needs +2 lines.
assert!(!use_expanded_level_spacing(base + 1, id));
assert!(use_expanded_level_spacing(base + 2, id));
}
} }
fn render_result(frame: &mut ratatui::Frame, app: &App) { fn render_result(frame: &mut ratatui::Frame, app: &App) {
@@ -2856,9 +3104,7 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
if is_editing_this_path { if is_editing_this_path {
if let Some((_, ref input)) = app.settings_editing_path { if let Some((_, ref input)) = app.settings_editing_path {
let (before, cursor_ch, after) = input.render_parts(); let (before, cursor_ch, after) = input.render_parts();
let cursor_style = Style::default() let cursor_style = Style::default().fg(colors.bg()).bg(colors.focused_key());
.fg(colors.bg())
.bg(colors.focused_key());
let path_spans = match cursor_ch { let path_spans = match cursor_ch {
Some(ch) => vec![ Some(ch) => vec![
Span::styled(format!(" {before}"), value_style), Span::styled(format!(" {before}"), value_style),
@@ -3009,7 +3255,6 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
} }
} }
fn render_code_language_select(frame: &mut ratatui::Frame, app: &App) { fn render_code_language_select(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area(); let area = frame.area();
let colors = &app.theme.colors; let colors = &app.theme.colors;
@@ -3715,6 +3960,7 @@ fn render_code_download_progress(frame: &mut ratatui::Frame, app: &App) {
fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) { fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area(); let area = frame.area();
let colors = &app.theme.colors;
let centered = skill_tree_popup_rect(area); let centered = skill_tree_popup_rect(area);
let widget = SkillTreeWidget::new( let widget = SkillTreeWidget::new(
&app.skill_tree, &app.skill_tree,
@@ -3724,6 +3970,89 @@ fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) {
app.theme, app.theme,
); );
frame.render_widget(widget, centered); frame.render_widget(widget, centered);
if let Some(branch_id) = app.skill_tree_confirm_unlock {
let sentence_one = "Once unlocked, the default adaptive drill will mix in keys in this branch that are unlocked.";
let sentence_two = "If you want to focus only on this branch, launch a drill directly from this branch in the Skill Tree.";
let branch_name = engine::skill_tree::get_branch_definition(branch_id).name;
let dialog_width = 72u16.min(area.width.saturating_sub(4));
let content_width = dialog_width.saturating_sub(6).max(1) as usize; // border + side margins
let body_required = 4 // blank + title + blank + blank-between-sentences
+ wrapped_line_count(sentence_one, content_width)
+ wrapped_line_count(sentence_two, content_width);
// Add one safety line because `wrapped_line_count` is a cheap estimator.
let body_required = body_required + 1;
let min_dialog_height = (body_required + 1 + 2) as u16; // body + prompt + border
let preferred_dialog_height = (body_required + 2 + 2) as u16; // + blank before prompt
let max_dialog_height = area.height.saturating_sub(1).max(7);
let dialog_height = preferred_dialog_height
.min(max_dialog_height)
.max(min_dialog_height.min(max_dialog_height));
let dialog_x = area.x + area.width.saturating_sub(dialog_width) / 2;
let dialog_y = area.y + area.height.saturating_sub(dialog_height) / 2;
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
frame.render_widget(ratatui::widgets::Clear, dialog_area);
let block = Block::bordered()
.title(" Confirm Unlock ")
.border_style(Style::default().fg(colors.error()))
.style(Style::default().bg(colors.bg()));
let inner = block.inner(dialog_area);
frame.render_widget(block, dialog_area);
let content = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(2),
Constraint::Min(1),
Constraint::Length(2),
])
.split(inner)[1];
let prompt_block_height = if content.height as usize > body_required + 1 {
2
} else {
1
};
let content_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(prompt_block_height)])
.split(content);
let body = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
format!("Unlock {branch_name}?"),
Style::default().fg(colors.fg()),
)),
Line::from(""),
Line::from(Span::styled(
sentence_one,
Style::default().fg(colors.text_pending()),
)),
Line::from(""),
Line::from(Span::styled(
sentence_two,
Style::default().fg(colors.text_pending()),
)),
])
.wrap(Wrap { trim: false })
.style(Style::default().bg(colors.bg()));
frame.render_widget(body, content_layout[0]);
let confirm_lines = if prompt_block_height > 1 {
vec![
Line::from(""),
Line::from(Span::styled(
"Proceed? (y/n)",
Style::default().fg(colors.fg()),
)),
]
} else {
vec![Line::from(Span::styled(
"Proceed? (y/n)",
Style::default().fg(colors.fg()),
))]
};
let confirm = Paragraph::new(confirm_lines).style(Style::default().bg(colors.bg()));
frame.render_widget(confirm, content_layout[1]);
}
} }
fn handle_keyboard_explorer_key(app: &mut App, key: KeyEvent) { fn handle_keyboard_explorer_key(app: &mut App, key: KeyEvent) {

View File

@@ -32,9 +32,8 @@ fn wrapped_branch_columns(area_width: u16, branch_count: usize) -> usize {
return 1; return 1;
} }
let width = area_width as usize; let width = area_width as usize;
let max_cols_by_width = ((width + BRANCH_CELL_GUTTER) let max_cols_by_width =
/ (MIN_BRANCH_CELL_WIDTH + BRANCH_CELL_GUTTER)) ((width + BRANCH_CELL_GUTTER) / (MIN_BRANCH_CELL_WIDTH + BRANCH_CELL_GUTTER)).max(1);
.max(1);
max_cols_by_width.min(branch_count) max_cols_by_width.min(branch_count)
} }

View File

@@ -160,12 +160,7 @@ impl Widget for Dashboard<'_> {
]; ];
let lines: Vec<Line> = pack_hint_lines(&hints, inner.width as usize) let lines: Vec<Line> = pack_hint_lines(&hints, inner.width as usize)
.into_iter() .into_iter()
.map(|line| { .map(|line| Line::from(Span::styled(line, Style::default().fg(colors.accent()))))
Line::from(Span::styled(
line,
Style::default().fg(colors.accent()),
))
})
.collect(); .collect();
Paragraph::new(lines) Paragraph::new(lines)
}; };

View File

@@ -59,6 +59,41 @@ pub fn detail_line_count(branch_id: BranchId) -> usize {
.sum::<usize>() .sum::<usize>()
} }
pub fn detail_line_count_with_level_spacing(branch_id: BranchId, level_spacing: bool) -> usize {
let base = detail_line_count(branch_id);
if !level_spacing {
return base;
}
let def = get_branch_definition(branch_id);
base + def.levels.len().saturating_sub(1)
}
pub fn use_expanded_level_spacing(detail_area_height: u16, branch_id: BranchId) -> bool {
let def = get_branch_definition(branch_id);
let base = detail_line_count(branch_id);
let extra = def.levels.len().saturating_sub(1);
(detail_area_height as usize) >= base + extra
}
pub fn use_side_by_side_layout(inner_width: u16) -> bool {
inner_width >= 100
}
pub fn branch_list_spacing_flags(branch_area_height: u16, branch_count: usize) -> (bool, bool) {
if branch_count == 0 {
return (false, false);
}
// Base lines: 2 per branch + 1 separator after lowercase.
let base_lines = branch_count * 2 + 1;
let extra_lines = (branch_area_height as usize).saturating_sub(base_lines);
// Priority 1: one spacer between each progress bar and following branch title.
let inter_branch_needed = branch_count.saturating_sub(1);
let inter_branch_spacing = extra_lines >= inter_branch_needed;
// Priority 2: one extra line above and below "Branches (...)" separator.
let separator_padding = inter_branch_spacing && extra_lines >= inter_branch_needed + 2;
(inter_branch_spacing, separator_padding)
}
impl Widget for SkillTreeWidget<'_> { impl Widget for SkillTreeWidget<'_> {
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;
@@ -70,9 +105,8 @@ impl Widget for SkillTreeWidget<'_> {
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); block.render(area, buf);
// Layout: branch list, separator, detail panel, footer (adaptive height) // Layout: main split (branch list + detail) and footer (adaptive height)
let branches = selectable_branches(); let branches = selectable_branches();
let branch_list_height = branches.len() as u16 * 2 + 1; // all branches * 2 lines + separator after Lowercase
let (footer_hints, footer_notice) = if self.selected < branches.len() { let (footer_hints, footer_notice) = if self.selected < branches.len() {
let bp = self.skill_tree.branch_progress(branches[self.selected]); let bp = self.skill_tree.branch_progress(branches[self.selected]);
if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked { if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked {
@@ -84,8 +118,17 @@ impl Widget for SkillTreeWidget<'_> {
], ],
Some("Complete a-z to unlock branches"), Some("Complete a-z to unlock branches"),
) )
} else if bp.status == BranchStatus::Available || bp.status == BranchStatus::InProgress } else if bp.status == BranchStatus::Available {
{ (
vec![
"[Enter] Unlock",
"[↑↓/jk] Navigate",
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
"[q] Back",
],
None,
)
} else if bp.status == BranchStatus::InProgress {
( (
vec![ vec![
"[Enter] Start Drill", "[Enter] Start Drill",
@@ -128,28 +171,67 @@ impl Widget for SkillTreeWidget<'_> {
let layout = Layout::default() let layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([Constraint::Min(4), Constraint::Length(footer_height)])
Constraint::Length(
branch_list_height.min(inner.height.saturating_sub(footer_height + 4)),
),
Constraint::Length(1),
Constraint::Min(4),
Constraint::Length(footer_height),
])
.split(inner); .split(inner);
// --- Branch list --- if use_side_by_side_layout(inner.width) {
self.render_branch_list(layout[0], buf, &branches); let main = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(42),
Constraint::Length(1),
Constraint::Percentage(58),
])
.split(layout[0]);
// --- Separator --- // --- Branch list (left pane) ---
let (inter_branch_spacing, separator_padding) =
branch_list_spacing_flags(main[0].height, branches.len());
self.render_branch_list(
main[0],
buf,
&branches,
inter_branch_spacing,
separator_padding,
);
// --- Vertical separator ---
let sep_lines: Vec<Line> = (0..main[1].height)
.map(|_| {
Line::from(Span::styled(
"\u{2502}",
Style::default().fg(colors.border()),
))
})
.collect();
Paragraph::new(sep_lines).render(main[1], buf);
// --- Detail panel for selected branch (right pane) ---
self.render_detail_panel(main[2], buf, &branches, true);
} else {
let branch_list_height = branches.len() as u16 * 2 + 1;
let main = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(branch_list_height.min(layout[0].height.saturating_sub(4))),
Constraint::Length(1),
Constraint::Min(3),
])
.split(layout[0]);
// --- Branch list (top pane) ---
self.render_branch_list(main[0], buf, &branches, false, false);
// --- Horizontal separator ---
let sep = Paragraph::new(Line::from(Span::styled( let sep = Paragraph::new(Line::from(Span::styled(
"\u{2500}".repeat(layout[1].width as usize), "\u{2500}".repeat(main[1].width as usize),
Style::default().fg(colors.border()), Style::default().fg(colors.border()),
))); )));
sep.render(layout[1], buf); sep.render(main[1], buf);
// --- Detail panel for selected branch --- // --- Detail panel (bottom pane) ---
self.render_detail_panel(layout[2], buf, &branches); self.render_detail_panel(main[2], buf, &branches, true);
}
// --- Footer --- // --- Footer ---
let mut footer_lines: Vec<Line> = Vec::new(); let mut footer_lines: Vec<Line> = Vec::new();
@@ -168,16 +250,27 @@ impl Widget for SkillTreeWidget<'_> {
)) ))
})); }));
let footer = Paragraph::new(footer_lines).wrap(Wrap { trim: false }); let footer = Paragraph::new(footer_lines).wrap(Wrap { trim: false });
footer.render(layout[3], buf); footer.render(layout[1], buf);
} }
} }
impl SkillTreeWidget<'_> { impl SkillTreeWidget<'_> {
fn render_branch_list(&self, area: Rect, buf: &mut Buffer, branches: &[BranchId]) { fn render_branch_list(
&self,
area: Rect,
buf: &mut Buffer,
branches: &[BranchId],
inter_branch_spacing: bool,
separator_padding: bool,
) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
for (i, &branch_id) in branches.iter().enumerate() { for (i, &branch_id) in branches.iter().enumerate() {
if i > 0 && inter_branch_spacing {
lines.push(Line::from(""));
}
let bp = self.skill_tree.branch_progress(branch_id); let bp = self.skill_tree.branch_progress(branch_id);
let def = get_branch_definition(branch_id); let def = get_branch_definition(branch_id);
let total_keys = def.levels.iter().map(|l| l.keys.len()).sum::<usize>(); let total_keys = def.levels.iter().map(|l| l.keys.len()).sum::<usize>();
@@ -249,10 +342,18 @@ impl SkillTreeWidget<'_> {
// Add separator after Lowercase (index 0) // Add separator after Lowercase (index 0)
if branch_id == BranchId::Lowercase { if branch_id == BranchId::Lowercase {
if separator_padding {
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
" \u{2500}\u{2500} Branches (unlocked after a-z) \u{2500}\u{2500}", " \u{2500}\u{2500} Branches (available after a-z) \u{2500}\u{2500}",
Style::default().fg(colors.border()), Style::default().fg(colors.text_pending()),
))); )));
// If inter-branch spacing is enabled, the next branch will already
// insert one blank line before its title.
if separator_padding && !inter_branch_spacing {
lines.push(Line::from(""));
}
} }
} }
@@ -260,7 +361,13 @@ impl SkillTreeWidget<'_> {
paragraph.render(area, buf); paragraph.render(area, buf);
} }
fn render_detail_panel(&self, area: Rect, buf: &mut Buffer, branches: &[BranchId]) { fn render_detail_panel(
&self,
area: Rect,
buf: &mut Buffer,
branches: &[BranchId],
allow_expanded_level_spacing: bool,
) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
if self.selected >= branches.len() { if self.selected >= branches.len() {
@@ -270,6 +377,8 @@ impl SkillTreeWidget<'_> {
let branch_id = branches[self.selected]; let branch_id = branches[self.selected];
let bp = self.skill_tree.branch_progress(branch_id); let bp = self.skill_tree.branch_progress(branch_id);
let def = get_branch_definition(branch_id); let def = get_branch_definition(branch_id);
let expanded_level_spacing =
allow_expanded_level_spacing && use_expanded_level_spacing(area.height, branch_id);
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
@@ -401,6 +510,10 @@ impl SkillTreeWidget<'_> {
])); ]));
} }
} }
if expanded_level_spacing && level_idx + 1 < def.levels.len() {
lines.push(Line::from(""));
}
} }
let visible_height = area.height as usize; let visible_height = area.height as usize;
@@ -437,4 +550,3 @@ fn dual_progress_bar_parts(
"\u{2591}".repeat(empty_cells), "\u{2591}".repeat(empty_cells),
) )
} }

View File

@@ -166,12 +166,7 @@ impl Widget for StatsDashboard<'_> {
// Footer // Footer
let footer_lines: Vec<Line> = footer_lines_vec let footer_lines: Vec<Line> = footer_lines_vec
.into_iter() .into_iter()
.map(|line| { .map(|line| Line::from(Span::styled(line, Style::default().fg(colors.accent()))))
Line::from(Span::styled(
line,
Style::default().fg(colors.accent()),
))
})
.collect(); .collect();
Paragraph::new(footer_lines).render(layout[2], buf); Paragraph::new(footer_lines).render(layout[2], buf);

View File

@@ -89,7 +89,8 @@ impl LineInput {
if self.cursor > 0 { if self.cursor > 0 {
let byte_offset = self.char_to_byte(self.cursor - 1); let byte_offset = self.char_to_byte(self.cursor - 1);
let ch = self.text[byte_offset..].chars().next().unwrap(); let ch = self.text[byte_offset..].chars().next().unwrap();
self.text.replace_range(byte_offset..byte_offset + ch.len_utf8(), ""); self.text
.replace_range(byte_offset..byte_offset + ch.len_utf8(), "");
self.cursor -= 1; self.cursor -= 1;
} }
} }
@@ -99,7 +100,8 @@ impl LineInput {
if self.cursor < len { if self.cursor < len {
let byte_offset = self.char_to_byte(self.cursor); let byte_offset = self.char_to_byte(self.cursor);
let ch = self.text[byte_offset..].chars().next().unwrap(); let ch = self.text[byte_offset..].chars().next().unwrap();
self.text.replace_range(byte_offset..byte_offset + ch.len_utf8(), ""); self.text
.replace_range(byte_offset..byte_offset + ch.len_utf8(), "");
} }
} }
KeyCode::Tab => { KeyCode::Tab => {
@@ -216,11 +218,7 @@ impl LineInput {
// Split seed into (dir_part, partial_filename) by last path separator. // Split seed into (dir_part, partial_filename) by last path separator.
// Accept both '/' and '\\' so user-typed alternate separators work on any platform. // Accept both '/' and '\\' so user-typed alternate separators work on any platform.
let last_sep_pos = seed let last_sep_pos = seed.rfind('/').into_iter().chain(seed.rfind('\\')).max();
.rfind('/')
.into_iter()
.chain(seed.rfind('\\'))
.max();
let (dir_str, partial) = if let Some(pos) = last_sep_pos { let (dir_str, partial) = if let Some(pos) = last_sep_pos {
(&seed[..=pos], &seed[pos + 1..]) (&seed[..=pos], &seed[pos + 1..])
} else { } else {
@@ -638,7 +636,10 @@ mod tests {
let mut input = LineInput::new(""); let mut input = LineInput::new("");
let entries: Vec<std::io::Result<(String, bool)>> = vec![ let entries: Vec<std::io::Result<(String, bool)>> = vec![
Ok(("alpha.txt".to_string(), false)), Ok(("alpha.txt".to_string(), false)),
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "mock")), Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"mock",
)),
Ok(("beta.txt".to_string(), false)), Ok(("beta.txt".to_string(), false)),
]; ];

View File

@@ -6,9 +6,7 @@ use std::sync::Once;
use chrono::Datelike; use chrono::Datelike;
use keydr::engine::scoring::level_from_score; use keydr::engine::scoring::level_from_score;
use keydr::engine::skill_tree::{ use keydr::engine::skill_tree::{ALL_BRANCHES, BranchId, BranchStatus, DrillScope, SkillTree};
BranchId, BranchStatus, DrillScope, SkillTree, ALL_BRANCHES,
};
use keydr::store::json_store::JsonStore; use keydr::store::json_store::JsonStore;
use keydr::store::schema::ExportData; use keydr::store::schema::ExportData;
@@ -34,7 +32,10 @@ fn ensure_profiles_generated() {
.args(["run", "--bin", "generate_test_profiles"]) .args(["run", "--bin", "generate_test_profiles"])
.status() .status()
.expect("failed to run generate_test_profiles"); .expect("failed to run generate_test_profiles");
assert!(status.success(), "generate_test_profiles exited with {status}"); assert!(
status.success(),
"generate_test_profiles exited with {status}"
);
}); });
} }
@@ -49,11 +50,7 @@ fn load_profile(name: &str) -> ExportData {
fn completed_branch_keys(data: &ExportData) -> HashSet<char> { fn completed_branch_keys(data: &ExportData) -> HashSet<char> {
let mut keys = HashSet::new(); let mut keys = HashSet::new();
for branch_def in ALL_BRANCHES { for branch_def in ALL_BRANCHES {
let bp = data let bp = data.profile.skill_tree.branches.get(branch_def.id.to_key());
.profile
.skill_tree
.branches
.get(branch_def.id.to_key());
let is_complete = matches!(bp, Some(bp) if bp.status == BranchStatus::Complete); let is_complete = matches!(bp, Some(bp) if bp.status == BranchStatus::Complete);
if is_complete { if is_complete {
for level in branch_def.levels { for level in branch_def.levels {
@@ -229,8 +226,16 @@ fn profile_01_has_empty_ranked_stats() {
data.ranked_key_stats.stats.stats.is_empty(), data.ranked_key_stats.stats.stats.is_empty(),
"01-brand-new.json: ranked_key_stats should be empty" "01-brand-new.json: ranked_key_stats should be empty"
); );
let ranked_count = data.drill_history.drills.iter().filter(|d| d.ranked).count(); let ranked_count = data
assert_eq!(ranked_count, 0, "01-brand-new.json: should have no ranked drills"); .drill_history
.drills
.iter()
.filter(|d| d.ranked)
.count();
assert_eq!(
ranked_count, 0,
"01-brand-new.json: should have no ranked drills"
);
} }
#[test] #[test]
@@ -241,7 +246,12 @@ fn profiles_02_to_07_have_ranked_stats_and_ranked_drills() {
!data.ranked_key_stats.stats.stats.is_empty(), !data.ranked_key_stats.stats.stats.is_empty(),
"{name}: ranked_key_stats should not be empty" "{name}: ranked_key_stats should not be empty"
); );
let ranked_count = data.drill_history.drills.iter().filter(|d| d.ranked).count(); let ranked_count = data
.drill_history
.drills
.iter()
.filter(|d| d.ranked)
.count();
assert!( assert!(
ranked_count > 0, ranked_count > 0,
"{name}: expected at least one ranked drill to populate ranked stores" "{name}: expected at least one ranked drill to populate ranked stores"
@@ -374,7 +384,12 @@ fn streak_and_last_practice_date_consistent_with_history() {
); );
} else { } else {
// last_practice_date should match the last drill's date // last_practice_date should match the last drill's date
let last_drill_date = drills.last().unwrap().timestamp.format("%Y-%m-%d").to_string(); let last_drill_date = drills
.last()
.unwrap()
.timestamp
.format("%Y-%m-%d")
.to_string();
assert_eq!( assert_eq!(
data.profile.last_practice_date.as_deref(), data.profile.last_practice_date.as_deref(),
Some(last_drill_date.as_str()), Some(last_drill_date.as_str()),
@@ -495,10 +510,7 @@ fn imports_all_profiles_into_temp_store() {
// Verify we can reload the imported data // Verify we can reload the imported data
let profile = store.load_profile(); let profile = store.load_profile();
assert!( assert!(profile.is_some(), "{name}: profile not found after import");
profile.is_some(),
"{name}: profile not found after import"
);
let profile = profile.unwrap(); let profile = profile.unwrap();
assert_eq!( assert_eq!(
profile.total_drills, data.profile.total_drills, profile.total_drills, data.profile.total_drills,