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

View File

@@ -8,11 +8,11 @@ use rand::{Rng, SeedableRng};
use keydr::config::Config;
use keydr::engine::key_stats::{KeyStat, KeyStatsStore};
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::store::schema::{
DrillHistoryData, ExportData, KeyStatsData, ProfileData, EXPORT_VERSION,
DrillHistoryData, EXPORT_VERSION, ExportData, KeyStatsData, ProfileData,
};
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.
fn drill_timestamp(base: DateTime<Utc>, day: u32, drill_in_day: u32) -> DateTime<Utc> {
base + chrono::Duration::days(day as i64)
+ chrono::Duration::seconds(drill_in_day as i64 * 120)
base + chrono::Duration::days(day as i64) + chrono::Duration::seconds(drill_in_day as i64 * 120)
}
/// 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> {
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 ─────────────────────────────────────────────────────
fn build_profile_01() -> ExportData {
let skill_tree = make_skill_tree_progress(vec![
(BranchId::Lowercase, BranchStatus::InProgress, 0),
]);
let skill_tree =
make_skill_tree_progress(vec![(BranchId::Lowercase, BranchStatus::InProgress, 0)]);
make_export(
ProfileData {
@@ -295,13 +295,12 @@ fn build_profile_01() -> ExportData {
fn build_profile_02() -> ExportData {
// 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![
(BranchId::Lowercase, BranchStatus::InProgress, 4),
]);
let skill_tree =
make_skill_tree_progress(vec![(BranchId::Lowercase, BranchStatus::InProgress, 4)]);
let all_keys = lowercase_keys(10);
let mastered_keys = &all_keys[..6]; // e,t,a,o,i,n
let partial_keys = &all_keys[6..]; // s,h,r,d
let partial_keys = &all_keys[6..]; // s,h,r,d
let mut rng = SmallRng::seed_from_u64(2002);
let mut stats = KeyStatsStore::default();
@@ -323,12 +322,11 @@ fn build_profile_02() -> ExportData {
} else {
(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
+ 6;
ranked_stats.stats.insert(
k,
make_key_stat(&mut rng, conf, sample_count),
);
let sample_count =
((base.sample_count as f64) * rng.gen_range(0.5..0.8)).round() as usize + 6;
ranked_stats
.stats
.insert(k, make_key_stat(&mut rng, conf, sample_count));
}
let drills = generate_drills(
@@ -359,9 +357,8 @@ fn build_profile_02() -> ExportData {
fn build_profile_03() -> ExportData {
// Lowercase InProgress level 12 => 6 + 12 = 18 keys through 'y'
let skill_tree = make_skill_tree_progress(vec![
(BranchId::Lowercase, BranchStatus::InProgress, 12),
]);
let skill_tree =
make_skill_tree_progress(vec![(BranchId::Lowercase, BranchStatus::InProgress, 12)]);
let all_keys = lowercase_keys(18);
let mastered_keys = &all_keys[..14];
@@ -389,10 +386,9 @@ fn build_profile_03() -> ExportData {
};
let sample_count =
((base.sample_count as f64) * rng.gen_range(0.52..0.82)).round() as usize + 8;
ranked_stats.stats.insert(
k,
make_key_stat(&mut rng, conf, sample_count),
);
ranked_stats
.stats
.insert(k, make_key_stat(&mut rng, conf, sample_count));
}
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 sample_count =
((base.sample_count as f64) * rng.gen_range(0.55..0.85)).round() as usize + 10;
ranked_stats.stats.insert(
k,
make_key_stat(&mut rng, conf, sample_count),
);
ranked_stats
.stats
.insert(k, make_key_stat(&mut rng, conf, sample_count));
}
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)
let mut ranked_stats = KeyStatsStore::default();
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
@@ -632,7 +629,9 @@ fn build_profile_06() -> ExportData {
// Ranked key stats: cover all keys used in ranked drills (all_unlocked)
let mut ranked_stats = KeyStatsStore::default();
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
@@ -711,7 +710,9 @@ fn build_profile_07() -> ExportData {
// Full ranked stats
let mut ranked_stats = KeyStatsStore::default();
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

View File

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

View File

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

View File

@@ -29,7 +29,6 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
use app::{App, AppScreen, DrillMode, MilestoneKind, StatusKind};
use ui::line_input::{InputResult, LineInput, PathField};
use engine::skill_tree::{DrillScope, find_key_branch};
use event::{AppEvent, EventHandler};
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::finger::Hand;
use ui::components::dashboard::Dashboard;
use ui::layout::{pack_hint_lines, wrapped_line_count};
use ui::components::keyboard_diagram::KeyboardDiagram;
use ui::components::skill_tree::{SkillTreeWidget, detail_line_count, selectable_branches};
use ui::components::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::{
AnomalyBigramRow, NgramTabData, StatsDashboard, history_page_size_for_terminal,
};
use ui::components::stats_sidebar::StatsSidebar;
use ui::components::typing_area::TypingArea;
use ui::layout::AppLayout;
use ui::layout::{pack_hint_lines, wrapped_line_count};
use ui::line_input::{InputResult, LineInput, PathField};
#[derive(Parser)]
#[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) {
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);
app.skill_tree_detail_scroll = app.skill_tree_detail_scroll.min(max_scroll);
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() {
let branch_id = branches[app.skill_tree_selected];
let status = app.skill_tree.branch_status(branch_id).clone();
if status == engine::skill_tree::BranchStatus::Available
|| status == engine::skill_tree::BranchStatus::InProgress
{
if status == engine::skill_tree::BranchStatus::Available {
app.skill_tree_confirm_unlock = Some(branch_id);
} else if status == engine::skill_tree::BranchStatus::InProgress {
app.start_branch_drill(branch_id);
}
}
@@ -1040,21 +1057,83 @@ fn skill_tree_detail_max_scroll(app: &App) -> usize {
if branches.is_empty() {
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
.skill_tree_selected
.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)
}
@@ -2252,7 +2331,7 @@ mod review_tests {
#[test]
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();
@@ -2402,7 +2481,10 @@ mod review_tests {
);
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);
}
@@ -2419,15 +2501,34 @@ mod review_tests {
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.
fn render_settings_to_string(app: &App) -> String {
let backend = ratatui::backend::TestBackend::new(80, 40);
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
.draw(|frame| render_settings(frame, app))
.draw(|frame| render_skill_tree(frame, app))
.unwrap();
let buf = terminal.backend().buffer().clone();
let mut text = String::new();
@@ -2440,6 +2541,10 @@ mod review_tests {
text
}
fn render_skill_tree_to_string(app: &App) -> String {
render_skill_tree_to_string_with_size(app, 120, 40)
}
#[test]
fn footer_shows_completion_error_and_clears_on_keystroke() {
let mut app = test_app();
@@ -2468,10 +2573,7 @@ mod review_tests {
);
// Press a non-tab key to clear the error
handle_settings_key(
&mut app,
KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
);
handle_settings_key(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
// Render again — error hint should be gone
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("[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) {
@@ -2856,9 +3104,7 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
if is_editing_this_path {
if let Some((_, ref input)) = app.settings_editing_path {
let (before, cursor_ch, after) = input.render_parts();
let cursor_style = Style::default()
.fg(colors.bg())
.bg(colors.focused_key());
let cursor_style = Style::default().fg(colors.bg()).bg(colors.focused_key());
let path_spans = match cursor_ch {
Some(ch) => vec![
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) {
let area = frame.area();
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) {
let area = frame.area();
let colors = &app.theme.colors;
let centered = skill_tree_popup_rect(area);
let widget = SkillTreeWidget::new(
&app.skill_tree,
@@ -3724,6 +3970,89 @@ fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) {
app.theme,
);
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) {

View File

@@ -32,9 +32,8 @@ fn wrapped_branch_columns(area_width: u16, branch_count: usize) -> usize {
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);
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)
}

View File

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

View File

@@ -59,6 +59,41 @@ pub fn detail_line_count(branch_id: BranchId) -> 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<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
@@ -70,9 +105,8 @@ impl Widget for SkillTreeWidget<'_> {
let inner = block.inner(area);
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 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 bp = self.skill_tree.branch_progress(branches[self.selected]);
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"),
)
} 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![
"[Enter] Start Drill",
@@ -128,28 +171,67 @@ impl Widget for SkillTreeWidget<'_> {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(
branch_list_height.min(inner.height.saturating_sub(footer_height + 4)),
),
Constraint::Length(1),
Constraint::Min(4),
Constraint::Length(footer_height),
])
.constraints([Constraint::Min(4), Constraint::Length(footer_height)])
.split(inner);
// --- Branch list ---
self.render_branch_list(layout[0], buf, &branches);
if use_side_by_side_layout(inner.width) {
let main = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(42),
Constraint::Length(1),
Constraint::Percentage(58),
])
.split(layout[0]);
// --- Separator ---
let sep = Paragraph::new(Line::from(Span::styled(
"\u{2500}".repeat(layout[1].width as usize),
Style::default().fg(colors.border()),
)));
sep.render(layout[1], buf);
// --- 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,
);
// --- Detail panel for selected branch ---
self.render_detail_panel(layout[2], buf, &branches);
// --- 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(
"\u{2500}".repeat(main[1].width as usize),
Style::default().fg(colors.border()),
)));
sep.render(main[1], buf);
// --- Detail panel (bottom pane) ---
self.render_detail_panel(main[2], buf, &branches, true);
}
// --- Footer ---
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 });
footer.render(layout[3], buf);
footer.render(layout[1], buf);
}
}
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 mut lines: Vec<Line> = Vec::new();
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 def = get_branch_definition(branch_id);
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)
if branch_id == BranchId::Lowercase {
if separator_padding {
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
" \u{2500}\u{2500} Branches (unlocked after a-z) \u{2500}\u{2500}",
Style::default().fg(colors.border()),
" \u{2500}\u{2500} Branches (available after a-z) \u{2500}\u{2500}",
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);
}
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;
if self.selected >= branches.len() {
@@ -270,6 +377,8 @@ impl SkillTreeWidget<'_> {
let branch_id = branches[self.selected];
let bp = self.skill_tree.branch_progress(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();
@@ -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;
@@ -437,4 +550,3 @@ fn dual_progress_bar_parts(
"\u{2591}".repeat(empty_cells),
)
}

View File

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

View File

@@ -89,7 +89,8 @@ impl LineInput {
if self.cursor > 0 {
let byte_offset = self.char_to_byte(self.cursor - 1);
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;
}
}
@@ -99,7 +100,8 @@ impl LineInput {
if self.cursor < len {
let byte_offset = self.char_to_byte(self.cursor);
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 => {
@@ -216,11 +218,7 @@ impl LineInput {
// Split seed into (dir_part, partial_filename) by last path separator.
// Accept both '/' and '\\' so user-typed alternate separators work on any platform.
let last_sep_pos = seed
.rfind('/')
.into_iter()
.chain(seed.rfind('\\'))
.max();
let last_sep_pos = seed.rfind('/').into_iter().chain(seed.rfind('\\')).max();
let (dir_str, partial) = if let Some(pos) = last_sep_pos {
(&seed[..=pos], &seed[pos + 1..])
} else {
@@ -638,7 +636,10 @@ mod tests {
let mut input = LineInput::new("");
let entries: Vec<std::io::Result<(String, bool)>> = vec![
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)),
];