Skill Tree page UI tweaks and improvements
This commit is contained in:
23
src/app.rs
23
src/app.rs
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
389
src/main.rs
389
src/main.rs
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)),
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user