rust_i18n::i18n!("locales", fallback = "en"); mod app; mod config; mod engine; mod event; mod generator; mod i18n; mod keyboard; mod l10n; mod session; mod store; mod ui; use std::io; use std::time::{Duration, Instant}; use anyhow::Result; use clap::Parser; use crossterm::event::{ DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, KeyboardEnhancementFlags, ModifierKeyCode, MouseButton, MouseEvent, MouseEventKind, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }; use crossterm::execute; use crossterm::terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Padding, Paragraph, Widget, Wrap}; use app::{App, AppScreen, DrillMode, MilestoneKind, SettingItem, StatusKind}; use i18n::t; use engine::skill_tree::{BranchStatus, DrillScope, find_key_branch, get_branch_definition}; use event::{AppEvent, EventHandler}; use generator::code_syntax::{code_language_options, is_language_cached, language_by_key}; use generator::passage::{is_book_cached, passage_options}; use keyboard::display::key_display_name; use keyboard::finger::Hand; use l10n::language_pack::{ CapabilityState, default_keyboard_layout_for_language, dictionary_languages_for_layout, find_language_pack, language_packs, validate_language_layout_pair, }; use ui::components::dashboard::Dashboard; use ui::components::keyboard_diagram::KeyboardDiagram; use ui::components::menu::Menu; use ui::components::skill_tree::{ SkillTreeWidget, branch_list_spacing_flags, detail_line_count_with_level_spacing_for_tree, selectable_branches, use_expanded_level_spacing_for_tree, 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( name = "keydr", version, about = "Terminal typing tutor with adaptive learning" )] struct Cli { #[arg(short, long, help = "Theme name")] theme: Option, #[arg( short, long, help = "Keyboard layout (qwerty, dvorak, colemak, de_qwertz, fr_azerty)" )] layout: Option, #[arg(short, long, help = "Number of words per drill")] words: Option, } fn main() -> Result<()> { let cli = Cli::parse(); let mut app = App::new(); i18n::set_ui_locale(&app.config.ui_language); if let Some(words) = cli.words { app.config.word_count = words; } if let Some(theme_name) = cli.theme { if let Some(theme) = ui::theme::Theme::load(&theme_name) { let theme: &'static ui::theme::Theme = Box::leak(Box::new(theme)); app.theme = theme; app.menu.theme = theme; } } if let Some(layout_key) = cli.layout { match app.set_keyboard_layout(&layout_key) { Ok(_capability) => { let _ = app.config.save(); } Err(err) => { eprintln!("Ignoring --layout {layout_key:?}: {err}"); } } } enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; // Request kitty keyboard protocol enhancements from the terminal. // - DISAMBIGUATE_ESCAPE_CODES: CSI u sequences for unambiguous key IDs, // enables CAPS_LOCK/NUM_LOCK state detection. // - REPORT_EVENT_TYPES: key release and repeat events (for depressed-key // tracking in the keyboard diagram). // - REPORT_ALL_KEYS_AS_ESCAPE_CODES: standalone modifier key events // (LeftShift, RightShift, etc.) so we can track shift state independently. // Falls back gracefully in terminals that don't support the protocol (tmux, // mosh, SSH, older emulators) — the execute! returns Err and we use // timer-based fallbacks instead. let keyboard_enhanced = execute!( io::stdout(), PushKeyboardEnhancementFlags( KeyboardEnhancementFlags::REPORT_EVENT_TYPES | KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES, ) ) .is_ok(); let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; terminal.hide_cursor()?; let events = EventHandler::new(Duration::from_millis(100)); let result = run_app(&mut terminal, &mut app, &events); if keyboard_enhanced { let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags); } disable_raw_mode()?; execute!( terminal.backend_mut(), DisableMouseCapture, LeaveAlternateScreen )?; terminal.show_cursor()?; if let Err(err) = result { eprintln!("Error: {err:?}"); } Ok(()) } fn run_app( terminal: &mut Terminal>, app: &mut App, events: &EventHandler, ) -> Result<()> { loop { terminal.draw(|frame| render(frame, app))?; match events.next()? { AppEvent::Key(key) => handle_key(app, key), AppEvent::Mouse(mouse) => handle_mouse(app, mouse), AppEvent::Tick => { if (app.screen == AppScreen::PassageIntro || app.screen == AppScreen::PassageDownloadProgress) && app.passage_intro_downloading { app.process_passage_download_tick(); } if (app.screen == AppScreen::CodeIntro || app.screen == AppScreen::CodeDownloadProgress) && app.code_intro_downloading { app.process_code_download_tick(); } // Fallback: clear depressed keys and shift state on a timer. // Needed because not all terminals send Release events (e.g. // WezTerm doesn't implement REPORT_EVENT_TYPES). Terminals that // DO send Release events handle cleanup in handle_key instead, // and the repeated Press events they send while a key is held // keep resetting last_key_time so this fallback never fires. // This causes a brief flicker (key clears, then re-appears when // OS key repeat kicks in after ~300-500ms), but that's an // acceptable tradeoff for responsive key press visualization. if let Some(last) = app.last_key_time { if last.elapsed() > Duration::from_millis(150) { if !app.depressed_keys.is_empty() { app.depressed_keys.clear(); } if app.shift_held { app.shift_held = false; } app.last_key_time = None; } } } AppEvent::Resize(_, _) => {} } if app.should_quit { return Ok(()); } } } fn handle_key(app: &mut App, key: KeyEvent) { // Track caps lock state via Kitty protocol metadata (KeyEventState::CAPS_LOCK). // Only Modifier key events reliably report lock state in WezTerm; regular // character events have empty state (0x0). So we only set caps_lock=true // when CAPS_LOCK appears, and only clear it from Modifier events that // reliably report state. if key.state.contains(KeyEventState::CAPS_LOCK) { app.caps_lock = true; } else if matches!(key.code, KeyCode::Modifier(_) | KeyCode::CapsLock) { // Modifier events and CapsLock key events reliably report lock state. // If CAPS_LOCK isn't in state, caps lock was toggled off. app.caps_lock = false; } // Determine whether the physical Shift key is held. When caps lock is on, // crossterm infers SHIFT from uppercase chars, so we need a heuristic: // caps lock + shift inverts case, producing lowercase. So if caps lock is // on and the char is uppercase, that's caps lock alone, not shift. let infer_shift = |ch: char, mods: KeyModifiers, caps: bool| -> bool { let has_shift = mods.contains(KeyModifiers::SHIFT); if caps && ch.is_alphabetic() { // Caps lock on: shift would invert to lowercase has_shift && ch.is_lowercase() } else { has_shift } }; // Track depressed keys and shift state for keyboard diagram match (&key.code, key.kind) { ( KeyCode::Modifier(ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift), KeyEventKind::Press | KeyEventKind::Repeat, ) => { app.shift_held = true; app.last_key_time = Some(Instant::now()); return; // Don't dispatch bare shift presses to screen handlers } ( KeyCode::Modifier(ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift), KeyEventKind::Release, ) => { app.shift_held = false; return; } (KeyCode::Char(ch), KeyEventKind::Press) => { let normalized = app.keyboard_model.shifted_to_base(*ch).unwrap_or(*ch); app.depressed_keys.insert(normalized); app.last_key_time = Some(Instant::now()); app.shift_held = infer_shift(*ch, key.modifiers, app.caps_lock); } (KeyCode::Char(ch), KeyEventKind::Release) => { let normalized = app.keyboard_model.shifted_to_base(*ch).unwrap_or(*ch); app.depressed_keys.remove(&normalized); return; // Don't process Release events as input } (KeyCode::Backspace, KeyEventKind::Press) => { app.depressed_keys.insert('\x08'); app.last_key_time = Some(Instant::now()); app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT); } (KeyCode::Backspace, KeyEventKind::Release) => { app.depressed_keys.remove(&'\x08'); return; } (KeyCode::Tab, KeyEventKind::Press) => { app.depressed_keys.insert('\t'); app.last_key_time = Some(Instant::now()); app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT); } (KeyCode::Tab, KeyEventKind::Release) => { app.depressed_keys.remove(&'\t'); return; } (KeyCode::Enter, KeyEventKind::Press) => { app.depressed_keys.insert('\n'); app.last_key_time = Some(Instant::now()); app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT); } (KeyCode::Enter, KeyEventKind::Release) => { app.depressed_keys.remove(&'\n'); return; } (_, KeyEventKind::Release) => return, _ => { app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT); } } // Only process Press events — ignore Repeat to avoid inflating input if key.kind != KeyEventKind::Press { return; } // Ctrl+C always quits, even during input lock. if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { app.should_quit = true; return; } // Briefly block all input right after a drill completes to avoid accidental // popup dismissal or continuation from trailing keystrokes. if app.post_drill_input_lock_remaining_ms().is_some() && (!app.milestone_queue.is_empty() || app.screen == AppScreen::DrillResult || app.screen == AppScreen::Drill) { return; } // Adaptive intro overlay intercepts input before milestone handling. if app.show_adaptive_intro { match key.code { KeyCode::Left => { app.config.target_wpm = app.config.target_wpm.saturating_sub(5).max(10); app.key_stats.target_cpm = app.config.target_cpm(); app.ranked_key_stats.target_cpm = app.config.target_cpm(); } KeyCode::Right => { app.config.target_wpm = (app.config.target_wpm + 5).min(200); app.key_stats.target_cpm = app.config.target_cpm(); app.ranked_key_stats.target_cpm = app.config.target_cpm(); } KeyCode::Esc | KeyCode::Char('q') => { app.show_adaptive_intro = false; app.config.adaptive_intro_done = true; let _ = app.config.save(); app.go_to_menu(); } _ => { app.show_adaptive_intro = false; app.config.adaptive_intro_done = true; let _ = app.config.save(); app.start_global_adaptive_drill(); } } return; } // Milestone overlays are modal: one key action applies and is consumed. if let Some(milestone) = app.milestone_queue.front() { let open_skill_tree = milestone_supports_skill_tree_shortcut(milestone) && matches!(key.code, KeyCode::Char(ch) if ch.eq_ignore_ascii_case(&'t')); app.milestone_queue.pop_front(); if open_skill_tree { app.go_to_skill_tree(); } return; } match app.screen { AppScreen::Menu => handle_menu_key(app, key), AppScreen::Drill => handle_drill_key(app, key), AppScreen::DrillResult => handle_result_key(app, key), AppScreen::StatsDashboard => handle_stats_key(app, key), AppScreen::Settings => handle_settings_key(app, key), AppScreen::DictionaryLanguageSelect => handle_dictionary_language_key(app, key), AppScreen::KeyboardLayoutSelect => handle_keyboard_layout_key(app, key), AppScreen::SkillTree => handle_skill_tree_key(app, key), AppScreen::CodeLanguageSelect => handle_code_language_key(app, key), AppScreen::PassageBookSelect => handle_passage_book_key(app, key), AppScreen::PassageIntro => handle_passage_intro_key(app, key), AppScreen::PassageDownloadProgress => handle_passage_download_progress_key(app, key), AppScreen::CodeIntro => handle_code_intro_key(app, key), AppScreen::CodeDownloadProgress => handle_code_download_progress_key(app, key), AppScreen::Keyboard => handle_keyboard_explorer_key(app, key), AppScreen::UiLanguageSelect => handle_ui_language_key(app, key), } } fn milestone_supports_skill_tree_shortcut(milestone: &app::KeyMilestonePopup) -> bool { matches!( milestone.kind, MilestoneKind::BranchesAvailable | MilestoneKind::BranchComplete ) } fn terminal_area() -> Rect { let (w, h) = crossterm::terminal::size().unwrap_or((120, 40)); Rect::new(0, 0, w, h) } fn point_in_rect(x: u16, y: u16, rect: Rect) -> bool { x >= rect.x && x < rect.x.saturating_add(rect.width) && y >= rect.y && y < rect.y.saturating_add(rect.height) } fn hint_token_at(area: Rect, hints: &[&str], x: u16, y: u16) -> Option { if !point_in_rect(x, y, area) { return None; } let prefix = " "; let separator = " "; let width = area.width as usize; if width == 0 || hints.is_empty() { return None; } let row = y.saturating_sub(area.y) as usize; let col = x.saturating_sub(area.x) as usize; let prefix_len = prefix.chars().count(); let sep_len = separator.chars().count(); let mut current_line = 0usize; let mut line_len = prefix_len; let mut has_hint_on_line = false; for hint in hints { if hint.is_empty() { continue; } let hint_len = hint.chars().count(); let candidate_len = if has_hint_on_line { line_len + sep_len + hint_len } else { line_len + hint_len }; if candidate_len > width && has_hint_on_line { current_line += 1; line_len = prefix_len; has_hint_on_line = false; } let start_col = if has_hint_on_line { line_len + sep_len } else { line_len }; let end_col = start_col + hint_len; if current_line == row && (start_col..end_col).contains(&col) { if let (Some(lb), Some(rb)) = (hint.find('['), hint.find(']')) && rb > lb + 1 { return Some(hint[lb + 1..rb].to_string()); } return None; } line_len = end_col; has_hint_on_line = true; } // Fallback for unexpected layout drift: use packed lines and bracket search. let lines = pack_hint_lines(hints, area.width as usize); if row >= lines.len() { return None; } let chars: Vec = lines[row].chars().collect(); if col >= chars.len() { return None; } for hint in hints { if hint.is_empty() { continue; } let line = &lines[row]; if let Some(start) = line.find(hint) { let start_col = line[..start].chars().count(); let end_col = start_col + hint.chars().count(); if (start_col..end_col).contains(&col) { if let (Some(lb), Some(rb)) = (hint.find('['), hint.find(']')) && rb > lb + 1 { return Some(hint[lb + 1..rb].to_string()); } } } } None } fn milestone_footer_hint_token_at( milestone: &app::KeyMilestonePopup, x: u16, y: u16, ) -> Option { if !milestone_supports_skill_tree_shortcut(milestone) { return None; } let area = terminal_area(); let is_key_milestone = matches!( milestone.kind, MilestoneKind::Unlock | MilestoneKind::Mastery ); let kbd_mode = if is_key_milestone { overlay_keyboard_mode(area.height) } else { 0 }; let overlay_height = match &milestone.kind { MilestoneKind::BranchesAvailable => 18u16.min(area.height.saturating_sub(2)), MilestoneKind::BranchComplete | MilestoneKind::AllKeysUnlocked | MilestoneKind::AllKeysMastered => 12u16.min(area.height.saturating_sub(2)), _ => match kbd_mode { 2 => 18u16.min(area.height.saturating_sub(2)), 1 => 14u16.min(area.height.saturating_sub(2)), _ => 10u16.min(area.height.saturating_sub(2)), }, }; let overlay_width = 60u16.min(area.width.saturating_sub(4)); let left = area.x + (area.width.saturating_sub(overlay_width)) / 2; let top = area.y + (area.height.saturating_sub(overlay_height)) / 2; let overlay_area = Rect::new(left, top, overlay_width, overlay_height); let inner = Block::bordered().inner(overlay_area); let footer_y = inner.y + inner.height.saturating_sub(1); let footer_area = Rect::new(inner.x, footer_y, inner.width, 1); let hint_skill_tree = ui::hint::hint(ui::hint::K_T, t!("milestones.hint_skill_tree_continue").as_ref()); let hint_any_key = t!("milestones.hint_any_key"); let hints: Vec<&str> = vec![hint_skill_tree.as_str(), hint_any_key.as_ref()]; hint_token_at( footer_area, &hints, x, y, ) } fn handle_mouse(app: &mut App, mouse: MouseEvent) { if app.post_drill_input_lock_remaining_ms().is_some() && (!app.milestone_queue.is_empty() || app.screen == AppScreen::DrillResult || app.screen == AppScreen::Drill) { return; } if !app.milestone_queue.is_empty() { if matches!( mouse.kind, MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) ) { if let Some(milestone) = app.milestone_queue.front() && milestone_footer_hint_token_at(milestone, mouse.column, mouse.row) .is_some_and(|t| t == "t") { app.milestone_queue.pop_front(); app.go_to_skill_tree(); return; } app.milestone_queue.pop_front(); } return; } match app.screen { AppScreen::Menu => handle_menu_mouse(app, mouse), AppScreen::Drill => handle_drill_mouse(app, mouse), AppScreen::DrillResult => handle_result_mouse(app, mouse), AppScreen::StatsDashboard => handle_stats_mouse(app, mouse), AppScreen::Settings => handle_settings_mouse(app, mouse), AppScreen::DictionaryLanguageSelect => handle_dictionary_language_mouse(app, mouse), AppScreen::KeyboardLayoutSelect => handle_keyboard_layout_mouse(app, mouse), AppScreen::SkillTree => handle_skill_tree_mouse(app, mouse), AppScreen::CodeLanguageSelect => handle_code_language_mouse(app, mouse), AppScreen::PassageBookSelect => handle_passage_book_mouse(app, mouse), AppScreen::PassageIntro => handle_passage_intro_mouse(app, mouse), AppScreen::PassageDownloadProgress => handle_passage_download_progress_mouse(app, mouse), AppScreen::CodeIntro => handle_code_intro_mouse(app, mouse), AppScreen::CodeDownloadProgress => handle_code_download_progress_mouse(app, mouse), AppScreen::Keyboard => handle_keyboard_explorer_mouse(app, mouse), AppScreen::UiLanguageSelect => handle_ui_language_mouse(app, mouse), } } fn activate_menu_selected(app: &mut App) { match app.menu.selected { 0 => { if !app.config.adaptive_intro_done { app.show_adaptive_intro = true; } else { app.start_global_adaptive_drill(); } } 1 => { if app.config.code_onboarding_done { app.go_to_code_language_select(); } else { app.go_to_code_intro(); } } 2 => { if app.config.passage_onboarding_done { app.go_to_passage_book_select(); } else { app.go_to_passage_intro(); } } 3 => app.go_to_skill_tree(), 4 => app.go_to_keyboard(), 5 => app.go_to_stats(), 6 => app.go_to_settings(), _ => {} } } fn handle_menu_mouse(app: &mut App, mouse: MouseEvent) { match mouse.kind { MouseEventKind::ScrollUp => app.menu.prev(), MouseEventKind::ScrollDown => app.menu.next(), MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { let is_secondary = matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)); let area = terminal_area(); let mh_start = ui::hint::hint(ui::hint::K_1_3, t!("menu.hint_start").as_ref()); let mh_tree = ui::hint::hint(ui::hint::K_T, t!("menu.hint_skill_tree").as_ref()); let mh_kbd = ui::hint::hint(ui::hint::K_B, t!("menu.hint_keyboard").as_ref()); let mh_stats = ui::hint::hint(ui::hint::K_S, t!("menu.hint_stats").as_ref()); let mh_settings = ui::hint::hint(ui::hint::K_C, t!("menu.hint_settings").as_ref()); let mh_quit = ui::hint::hint(ui::hint::K_Q, t!("menu.hint_quit").as_ref()); let menu_hints: Vec<&str> = vec![ mh_start.as_str(), mh_tree.as_str(), mh_kbd.as_str(), mh_stats.as_str(), mh_settings.as_str(), mh_quit.as_str(), ]; let footer_line_count = pack_hint_lines(&menu_hints, area.width as usize) .len() .max(1) as u16; let layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Min(0), Constraint::Length(footer_line_count), ]) .split(area); if let Some(token) = hint_token_at(layout[2], &menu_hints, mouse.column, mouse.row) { match token.as_str() { "1-3" => { let mut selected = app.menu.selected.min(2); selected = if is_secondary { if selected == 0 { 2 } else { selected - 1 } } else { (selected + 1) % 3 }; app.menu.selected = selected; activate_menu_selected(app); } "t" => { app.menu.selected = 3; activate_menu_selected(app); } "b" => { app.menu.selected = 4; activate_menu_selected(app); } "s" => { app.menu.selected = 5; activate_menu_selected(app); } "c" => { app.menu.selected = 6; activate_menu_selected(app); } "q" => app.should_quit = true, _ => {} } return; } let menu_area = ui::layout::centered_rect(50, 80, layout[1]); let inner = Block::bordered().inner(menu_area); let sections = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(5), Constraint::Length(1), Constraint::Min(0), ]) .split(inner); let list_area = sections[2]; if point_in_rect(mouse.column, mouse.row, list_area) { let row = ((mouse.row - list_area.y) / 3) as usize; if row < Menu::item_count() { app.menu.selected = row; activate_menu_selected(app); } } } _ => {} } } fn handle_drill_mouse(app: &mut App, mouse: MouseEvent) { if !matches!( mouse.kind, MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) ) { return; } let layout = AppLayout::new(terminal_area()); if point_in_rect(mouse.column, mouse.row, layout.footer) { let hint_end = ui::hint::hint(ui::hint::K_ESC, t!("drill.hint_end").as_ref()); let hint_bs = ui::hint::hint(ui::hint::K_BACKSPACE, t!("drill.hint_backspace").as_ref()); let hints: Vec<&str> = vec![hint_end.as_str(), hint_bs.as_str()]; if let Some(token) = hint_token_at(layout.footer, &hints, mouse.column, mouse.row) { match token.as_str() { "ESC" => { let has_progress = app.drill.as_ref().is_some_and(|d| d.cursor > 0); if has_progress { app.finish_partial_drill(); } else { app.go_to_menu(); } } "Backspace" => app.backspace(), _ => {} } return; } if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { let has_progress = app.drill.as_ref().is_some_and(|d| d.cursor > 0); if has_progress { app.finish_partial_drill(); } else { app.go_to_menu(); } } } } fn delete_confirm_dialog_area() -> Rect { let area = terminal_area(); let dialog_width = 34u16; let dialog_height = 5u16; let dialog_x = area.x + area.width.saturating_sub(dialog_width) / 2; let dialog_y = area.y + area.height.saturating_sub(dialog_height) / 2; Rect::new(dialog_x, dialog_y, dialog_width, dialog_height) } fn handle_result_mouse(app: &mut App, mouse: MouseEvent) { if !matches!( mouse.kind, MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) ) { return; } if app.history_confirm_delete && !app.drill_history.is_empty() { let dialog = delete_confirm_dialog_area(); if point_in_rect(mouse.column, mouse.row, dialog) { if mouse.column < dialog.x + dialog.width / 2 { app.delete_session(); app.history_confirm_delete = false; app.continue_drill(); } else { app.history_confirm_delete = false; } } return; } if app.last_result.is_some() { let area = terminal_area(); let centered = ui::layout::centered_rect(60, 70, area); let inner = Block::bordered().inner(centered); let h_cont = ui::hint::hint(ui::hint::K_C_ENTER_SPACE, t!("dashboard.hint_continue").as_ref()); let h_retry = ui::hint::hint(ui::hint::K_R, t!("dashboard.hint_retry").as_ref()); let h_menu = ui::hint::hint(ui::hint::K_Q, t!("dashboard.hint_menu").as_ref()); let h_stats = ui::hint::hint(ui::hint::K_S, t!("dashboard.hint_stats").as_ref()); let h_del = ui::hint::hint(ui::hint::K_X, t!("dashboard.hint_delete").as_ref()); let hints: Vec<&str> = vec![ h_cont.as_str(), h_retry.as_str(), h_menu.as_str(), h_stats.as_str(), h_del.as_str(), ]; let footer_line_count = pack_hint_lines(&hints, inner.width as usize).len().max(1) as u16; let footer_y = inner .y .saturating_add(inner.height.saturating_sub(footer_line_count)); let footer_area = Rect::new(inner.x, footer_y, inner.width, footer_line_count); if let Some(token) = hint_token_at(footer_area, &hints, mouse.column, mouse.row) { match token.as_str() { "c/Enter/Space" => app.continue_drill(), "r" => app.retry_drill(), "q" => app.go_to_menu(), "s" => app.go_to_stats(), "x" => { if !app.drill_history.is_empty() { app.history_selected = 0; app.history_confirm_delete = true; } } _ => {} } return; } } if app.last_result.is_some() { app.continue_drill(); } } fn stats_tab_labels() -> [String; 6] { [ t!("stats.tab_dashboard").to_string(), t!("stats.tab_history").to_string(), t!("stats.tab_activity").to_string(), t!("stats.tab_accuracy").to_string(), t!("stats.tab_timing").to_string(), t!("stats.tab_ngrams").to_string(), ] } fn wrapped_stats_tab_line_count(width: usize) -> usize { let labels = stats_tab_labels(); let mut lines = 1usize; let mut current_width = 0usize; for label in &labels { let item_width = format!(" {label} ").chars().count() + 2; if current_width > 0 && current_width + item_width > width { lines += 1; current_width = 0; } current_width += item_width; } lines.max(1) } fn stats_tab_at_point(tab_area: Rect, width: usize, x: u16, y: u16) -> Option { let labels = stats_tab_labels(); let mut row = tab_area.y; let mut col = tab_area.x; let max_col = tab_area.x + width as u16; for (idx, label) in labels.iter().enumerate() { let text = format!(" {label} "); let text_width = text.chars().count() as u16; let item_width = text_width + 2; // separator if col > tab_area.x && col + item_width > max_col { row = row.saturating_add(1); col = tab_area.x; } if y == row && x >= col && x < col + text_width { return Some(idx); } col = col.saturating_add(item_width); } None } fn handle_stats_mouse(app: &mut App, mouse: MouseEvent) { const STATS_TAB_COUNT: usize = 6; if app.history_confirm_delete { if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { let dialog = delete_confirm_dialog_area(); if point_in_rect(mouse.column, mouse.row, dialog) { if mouse.column < dialog.x + dialog.width / 2 { app.delete_session(); app.history_confirm_delete = false; } else { app.history_confirm_delete = false; } } } return; } if app.drill_history.is_empty() { return; } let area = terminal_area(); let inner = Block::bordered().inner(area); let width = inner.width as usize; let tab_line_count = wrapped_stats_tab_line_count(width) as u16; let sh_back = ui::hint::hint(ui::hint::K_Q_ESC, t!("stats.hint_back").as_ref()); let sh_next = ui::hint::hint(ui::hint::K_TAB, t!("stats.hint_next_tab").as_ref()); let sh_switch = ui::hint::hint(ui::hint::K_1_6, t!("stats.hint_switch_tab").as_ref()); let sh_nav = ui::hint::hint(ui::hint::K_J_K, t!("stats.hint_navigate").as_ref()); let sh_page = ui::hint::hint(ui::hint::K_PGUP_PGDN, t!("stats.hint_page").as_ref()); let sh_del = ui::hint::hint(ui::hint::K_X, t!("stats.hint_delete").as_ref()); let footer_hints: Vec<&str> = if app.stats_tab == 1 { vec![ sh_back.as_str(), sh_next.as_str(), sh_switch.as_str(), sh_nav.as_str(), sh_page.as_str(), sh_del.as_str(), ] } else { vec![sh_back.as_str(), sh_next.as_str(), sh_switch.as_str()] }; let footer_line_count = pack_hint_lines(&footer_hints, width).len().max(1) as u16; let layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(tab_line_count), Constraint::Min(10), Constraint::Length(footer_line_count), ]) .split(inner); match mouse.kind { MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { let is_secondary = matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)); if let Some(token) = hint_token_at(layout[2], &footer_hints, mouse.column, mouse.row) { match token.as_str() { "q/ESC" => app.go_to_menu(), "Tab" => { app.stats_tab = if is_secondary { if app.stats_tab == 0 { STATS_TAB_COUNT - 1 } else { app.stats_tab - 1 } } else { (app.stats_tab + 1) % STATS_TAB_COUNT }; } "1-6" => { app.stats_tab = if is_secondary { if app.stats_tab == 0 { STATS_TAB_COUNT - 1 } else { app.stats_tab - 1 } } else { (app.stats_tab + 1) % STATS_TAB_COUNT }; } "j/k" => { if app.stats_tab == 1 && !app.drill_history.is_empty() { if is_secondary { app.history_selected = app.history_selected.saturating_sub(1); } else { app.history_selected = (app.history_selected + 1).min(app.drill_history.len() - 1); } keep_history_selection_visible(app, current_history_page_size()); } } "PgUp/PgDn" => { if app.stats_tab == 1 && !app.drill_history.is_empty() { let page_size = current_history_page_size(); if is_secondary { let max_idx = app.drill_history.len() - 1; app.history_selected = (app.history_selected + page_size).min(max_idx); } else { app.history_selected = app.history_selected.saturating_sub(page_size); } keep_history_selection_visible(app, page_size); } } "x" => { if app.stats_tab == 1 && !app.drill_history.is_empty() { app.history_confirm_delete = true; } } _ => {} } return; } if point_in_rect(mouse.column, mouse.row, layout[0]) && let Some(tab) = stats_tab_at_point(layout[0], width, mouse.column, mouse.row) { app.stats_tab = tab; return; } if app.stats_tab == 1 { let table_inner = Block::bordered().inner(layout[1]); if point_in_rect(mouse.column, mouse.row, table_inner) && mouse.row >= table_inner.y.saturating_add(2) { let row = (mouse.row - table_inner.y.saturating_add(2)) as usize; let idx = app.history_scroll + row; if idx < app.drill_history.len() { app.history_selected = idx; keep_history_selection_visible(app, current_history_page_size()); } } } } MouseEventKind::ScrollUp => { if app.stats_tab == 1 { app.history_selected = app.history_selected.saturating_sub(1); keep_history_selection_visible(app, current_history_page_size()); } else { app.stats_tab = app.stats_tab.saturating_sub(1); } } MouseEventKind::ScrollDown => { if app.stats_tab == 1 { if !app.drill_history.is_empty() { app.history_selected = (app.history_selected + 1).min(app.drill_history.len() - 1); keep_history_selection_visible(app, current_history_page_size()); } } else { app.stats_tab = (app.stats_tab + 1).min(STATS_TAB_COUNT - 1); } } _ => {} } } fn settings_fields(app: &App) -> Vec<(SettingItem, String, String)> { let dictionary_language_label = find_language_pack(&app.config.dictionary_language) .map(|pack| pack.autonym.to_string()) .unwrap_or_else(|| app.config.dictionary_language.clone()); let keyboard_layout_label = app.config.keyboard_layout.clone(); vec![ ( SettingItem::TargetWpm, t!("settings.target_wpm").to_string(), format!("{}", app.config.target_wpm), ), ( SettingItem::Theme, t!("settings.theme").to_string(), app.config.theme.clone(), ), ( SettingItem::WordCount, t!("settings.word_count").to_string(), format!("{}", app.config.word_count), ), ( SettingItem::UiLanguage, t!("settings.ui_language").to_string(), find_language_pack(&app.config.ui_language) .map(|pack| pack.autonym.to_string()) .unwrap_or_else(|| app.config.ui_language.clone()), ), ( SettingItem::DictionaryLanguage, t!("settings.dictionary_language").to_string(), dictionary_language_label, ), ( SettingItem::KeyboardLayout, t!("settings.keyboard_layout").to_string(), keyboard_layout_label, ), ( SettingItem::CodeLanguage, t!("settings.code_language").to_string(), app.config.code_language.clone(), ), ( SettingItem::CodeDownloads, t!("settings.code_downloads").to_string(), if app.config.code_downloads_enabled { t!("settings.on").to_string() } else { t!("settings.off").to_string() }, ), ( SettingItem::CodeDownloadDir, t!("settings.code_download_dir").to_string(), app.config.code_download_dir.clone(), ), ( SettingItem::SnippetsPerRepo, t!("settings.snippets_per_repo").to_string(), if app.config.code_snippets_per_repo == 0 { t!("settings.unlimited").to_string() } else { format!("{}", app.config.code_snippets_per_repo) }, ), ( SettingItem::DownloadCodeNow, t!("settings.download_code_now").to_string(), t!("settings.run_downloader").to_string(), ), ( SettingItem::PassageDownloads, t!("settings.passage_downloads").to_string(), if app.config.passage_downloads_enabled { t!("settings.on").to_string() } else { t!("settings.off").to_string() }, ), ( SettingItem::PassageDownloadDir, t!("settings.passage_download_dir").to_string(), app.config.passage_download_dir.clone(), ), ( SettingItem::ParagraphsPerBook, t!("settings.paragraphs_per_book").to_string(), if app.config.passage_paragraphs_per_book == 0 { t!("settings.whole_book").to_string() } else { format!("{}", app.config.passage_paragraphs_per_book) }, ), ( SettingItem::DownloadPassagesNow, t!("settings.download_passages_now").to_string(), t!("settings.run_downloader").to_string(), ), ( SettingItem::ExportPath, t!("settings.export_path").to_string(), app.settings_export_path.clone(), ), ( SettingItem::ExportData, t!("settings.export_data").to_string(), t!("settings.export_now").to_string(), ), ( SettingItem::ImportPath, t!("settings.import_path").to_string(), app.settings_import_path.clone(), ), ( SettingItem::ImportData, t!("settings.import_data").to_string(), t!("settings.import_now").to_string(), ), ] } fn handle_settings_mouse(app: &mut App, mouse: MouseEvent) { let is_click = matches!( mouse.kind, MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) ); let is_secondary = matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)); match mouse.kind { MouseEventKind::ScrollUp => { app.settings_selected = app.settings_selected.saturating_sub(1); return; } MouseEventKind::ScrollDown => { let max_settings = SettingItem::ALL.len().saturating_sub(1); app.settings_selected = (app.settings_selected + 1).min(max_settings); return; } MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => {} _ => return, } if app.settings_status_message.is_some() { app.settings_status_message = None; return; } if app.settings_export_conflict { let area = terminal_area(); let dialog_width = 52u16.min(area.width.saturating_sub(4)); let dialog_height = 6u16; 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 = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height); if point_in_rect(mouse.column, mouse.row, dialog) { let third = dialog.width / 3; if mouse.column < dialog.x + third { app.settings_export_conflict = false; app.export_data_overwrite(); } else if mouse.column < dialog.x + 2 * third { app.settings_export_conflict = false; app.export_data_rename(); } else { app.settings_export_conflict = false; } } return; } if app.settings_confirm_import { let area = terminal_area(); let dialog_width = 52u16.min(area.width.saturating_sub(4)); let dialog_height = 7u16; 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 = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height); if point_in_rect(mouse.column, mouse.row, dialog) { if mouse.column < dialog.x + dialog.width / 2 { app.settings_confirm_import = false; app.import_data(); i18n::set_ui_locale(&app.config.ui_language); } else { app.settings_confirm_import = false; } } return; } let area = terminal_area(); let centered = ui::layout::centered_rect(60, 80, area); let inner = Block::bordered().inner(centered); let fields = settings_fields(app); let header_height = if inner.height > 0 { 1 } else { 0 }; let sfh_save = ui::hint::hint(ui::hint::K_ESC, t!("settings.hint_save_back").as_ref()); let sfh_change = ui::hint::hint(ui::hint::K_ENTER_ARROWS, t!("settings.hint_change_value").as_ref()); let sfh_edit = ui::hint::hint(ui::hint::K_ENTER_ON_PATH, t!("settings.hint_edit_path").as_ref()); let footer_hints: Vec<&str> = vec![ sfh_save.as_str(), sfh_change.as_str(), sfh_edit.as_str(), ]; let footer_height = if inner.height > header_height { pack_hint_lines(&footer_hints, inner.width as usize) .len() .max(1) as u16 } else { 0 }; let field_height = inner.height.saturating_sub(header_height + footer_height); let layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(header_height), Constraint::Length(field_height), Constraint::Length(footer_height), ]) .split(inner); if is_click && layout[2].height > 0 { let efh_move = ui::hint::hint(ui::hint::K_ARROW_LR, t!("settings.hint_move").as_ref()); let efh_tab = ui::hint::hint(ui::hint::K_TAB, t!("settings.hint_tab_complete").as_ref()); let efh_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("settings.hint_confirm").as_ref()); let efh_cancel = ui::hint::hint(ui::hint::K_ESC, t!("settings.hint_cancel").as_ref()); let footer_hints: Vec<&str> = if app.settings_editing_path.is_some() { vec![ efh_move.as_str(), efh_tab.as_str(), efh_confirm.as_str(), efh_cancel.as_str(), ] } else { vec![ sfh_save.as_str(), sfh_change.as_str(), sfh_edit.as_str(), ] }; if let Some(token) = hint_token_at(layout[2], &footer_hints, mouse.column, mouse.row) { if app.settings_editing_path.is_some() { match token.as_str() { "←→" => { let code = if is_secondary { KeyCode::Left } else { KeyCode::Right }; handle_settings_key(app, KeyEvent::new(code, KeyModifiers::NONE)); } "Tab" => handle_settings_key( app, KeyEvent::new( if is_secondary { KeyCode::BackTab } else { KeyCode::Tab }, KeyModifiers::NONE, ), ), "Enter" => { handle_settings_key(app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) } "ESC" => { handle_settings_key(app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) } _ => {} } } else { match token.as_str() { "ESC" => { handle_settings_key(app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) } "Enter/arrows" => { let code = if is_secondary { KeyCode::Right } else { KeyCode::Enter }; handle_settings_key(app, KeyEvent::new(code, KeyModifiers::NONE)); } "Enter on path" => { handle_settings_key(app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) } _ => {} } } return; } } if app.settings_editing_path.is_some() { return; } let row_height = 2u16; let visible_rows = (layout[1].height / row_height).max(1) as usize; let max_start = fields.len().saturating_sub(visible_rows); let start = app .settings_selected .saturating_sub(visible_rows.saturating_sub(1)) .min(max_start); let end = (start + visible_rows).min(fields.len()); let visible_fields = &fields[start..end]; let field_layout = Layout::default() .direction(Direction::Vertical) .constraints( visible_fields .iter() .map(|_| Constraint::Length(row_height)) .collect::>(), ) .split(layout[1]); for (row, _) in visible_fields.iter().enumerate() { let rect = field_layout[row]; if point_in_rect(mouse.column, mouse.row, rect) { let idx = start + row; app.settings_selected = idx; let setting = SettingItem::from_index(idx); let is_button = setting.is_action_button(); let is_path = setting.is_path_field(); let value_row = mouse.row > rect.y; if is_button || is_path || value_row { activate_settings_selected(app); } break; } } } fn handle_menu_key(app: &mut App, key: KeyEvent) { match key.code { KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, KeyCode::Char('1') => { app.menu.selected = 0; app.start_global_adaptive_drill(); } KeyCode::Char('2') => { app.menu.selected = 1; activate_menu_selected(app); } KeyCode::Char('3') => { app.menu.selected = 2; activate_menu_selected(app); } KeyCode::Char('t') => { app.menu.selected = 3; activate_menu_selected(app); } KeyCode::Char('b') => { app.menu.selected = 4; activate_menu_selected(app); } KeyCode::Char('s') => { app.menu.selected = 5; activate_menu_selected(app); } KeyCode::Char('c') => { app.menu.selected = 6; activate_menu_selected(app); } KeyCode::Up | KeyCode::Char('k') => app.menu.prev(), KeyCode::Down | KeyCode::Char('j') => app.menu.next(), KeyCode::Enter => activate_menu_selected(app), _ => {} } } fn handle_drill_key(app: &mut App, key: KeyEvent) { // Route Enter/Tab as typed characters during active drills if app.drill.is_some() { match key.code { KeyCode::Enter => { app.type_char('\n'); return; } KeyCode::Tab => { app.type_char('\t'); return; } KeyCode::BackTab => return, // Ignore Shift+Tab _ => {} } } match key.code { KeyCode::Esc => { let has_progress = app.drill.as_ref().is_some_and(|d| d.cursor > 0); if has_progress { app.finish_partial_drill(); } else { app.go_to_menu(); } } KeyCode::Backspace => app.backspace(), KeyCode::Char(ch) => app.type_char(ch), _ => {} } } fn handle_result_key(app: &mut App, key: KeyEvent) { if app.history_confirm_delete { match key.code { KeyCode::Char('y') => { app.delete_session(); app.history_confirm_delete = false; app.continue_drill(); } KeyCode::Char('n') | KeyCode::Esc => { app.history_confirm_delete = false; } _ => {} } return; } match key.code { KeyCode::Char('c') | KeyCode::Enter | KeyCode::Char(' ') => app.continue_drill(), KeyCode::Char('r') => app.retry_drill(), KeyCode::Char('q') | KeyCode::Esc => app.go_to_menu(), KeyCode::Char('s') => app.go_to_stats(), KeyCode::Char('x') => { if !app.drill_history.is_empty() { // On result screen, delete always targets the just-completed (most recent) session. app.history_selected = 0; app.history_confirm_delete = true; } } _ => {} } } fn handle_stats_key(app: &mut App, key: KeyEvent) { const STATS_TAB_COUNT: usize = 6; // Confirmation dialog takes priority if app.history_confirm_delete { match key.code { KeyCode::Char('y') => { app.delete_session(); app.history_confirm_delete = false; } KeyCode::Char('n') | KeyCode::Esc => { app.history_confirm_delete = false; } _ => {} } return; } // History tab has row navigation if app.stats_tab == 1 { let page_size = current_history_page_size(); match key.code { KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), KeyCode::Char('j') | KeyCode::Down => { if !app.drill_history.is_empty() { let max_idx = app.drill_history.len() - 1; app.history_selected = (app.history_selected + 1).min(max_idx); keep_history_selection_visible(app, page_size); } } KeyCode::Char('k') | KeyCode::Up => { app.history_selected = app.history_selected.saturating_sub(1); keep_history_selection_visible(app, page_size); } KeyCode::PageDown => { if !app.drill_history.is_empty() { let max_idx = app.drill_history.len() - 1; app.history_selected = (app.history_selected + page_size).min(max_idx); keep_history_selection_visible(app, page_size); } } KeyCode::PageUp => { app.history_selected = app.history_selected.saturating_sub(page_size); keep_history_selection_visible(app, page_size); } KeyCode::Char('x') | KeyCode::Delete => { if !app.drill_history.is_empty() { app.history_confirm_delete = true; } } KeyCode::Char('1') => app.stats_tab = 0, KeyCode::Char('2') => {} // already on history KeyCode::Char('3') => app.stats_tab = 2, KeyCode::Char('4') => app.stats_tab = 3, KeyCode::Char('5') => app.stats_tab = 4, KeyCode::Char('6') => app.stats_tab = 5, KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % STATS_TAB_COUNT, KeyCode::BackTab => { app.stats_tab = if app.stats_tab == 0 { STATS_TAB_COUNT - 1 } else { app.stats_tab - 1 } } _ => {} } return; } match key.code { KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), KeyCode::Char('1') => app.stats_tab = 0, KeyCode::Char('2') => app.stats_tab = 1, KeyCode::Char('3') => app.stats_tab = 2, KeyCode::Char('4') => app.stats_tab = 3, KeyCode::Char('5') => app.stats_tab = 4, KeyCode::Char('6') => app.stats_tab = 5, KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % STATS_TAB_COUNT, KeyCode::BackTab => { app.stats_tab = if app.stats_tab == 0 { STATS_TAB_COUNT - 1 } else { app.stats_tab - 1 } } _ => {} } } fn activate_settings_selected(app: &mut App) { match SettingItem::from_index(app.settings_selected) { SettingItem::UiLanguage => app.go_to_ui_language_select(), SettingItem::DictionaryLanguage => app.go_to_dictionary_language_select(), SettingItem::KeyboardLayout => app.go_to_keyboard_layout_select(), SettingItem::CodeDownloadDir => { app.clear_settings_modals(); app.settings_editing_path = Some(( PathField::CodeDownloadDir, LineInput::new(&app.config.code_download_dir), )); } SettingItem::PassageDownloadDir => { app.clear_settings_modals(); app.settings_editing_path = Some(( PathField::PassageDownloadDir, LineInput::new(&app.config.passage_download_dir), )); } SettingItem::DownloadCodeNow => app.start_code_downloads_from_settings(), SettingItem::DownloadPassagesNow => app.start_passage_downloads_from_settings(), SettingItem::ExportPath => { app.clear_settings_modals(); app.settings_editing_path = Some(( PathField::ExportPath, LineInput::new(&app.settings_export_path), )); } SettingItem::ExportData => { app.export_data(); } SettingItem::ImportPath => { app.clear_settings_modals(); app.settings_editing_path = Some(( PathField::ImportPath, LineInput::new(&app.settings_import_path), )); } SettingItem::ImportData => { app.clear_settings_modals(); app.settings_confirm_import = true; } _ => app.settings_cycle_forward(), } } fn handle_settings_key(app: &mut App, key: KeyEvent) { let max_settings: usize = SettingItem::ALL.len().saturating_sub(1); // Priority 1: dismiss status message if app.settings_status_message.is_some() { app.settings_status_message = None; return; } // Priority 2: export conflict dialog if app.settings_export_conflict { match key.code { KeyCode::Char('d') => { app.settings_export_conflict = false; app.export_data_overwrite(); } KeyCode::Char('r') => { app.settings_export_conflict = false; app.export_data_rename(); } KeyCode::Esc => { app.settings_export_conflict = false; } _ => {} } return; } // Priority 3: import confirmation dialog if app.settings_confirm_import { match key.code { KeyCode::Char('y') => { app.settings_confirm_import = false; app.import_data(); i18n::set_ui_locale(&app.config.ui_language); } KeyCode::Char('n') | KeyCode::Esc => { app.settings_confirm_import = false; } _ => {} } return; } // Priority 4: editing a path field if let Some((field, ref mut input)) = app.settings_editing_path { match input.handle(key) { InputResult::Submit => { let value = input.value().to_string(); match field { PathField::CodeDownloadDir => app.config.code_download_dir = value, PathField::PassageDownloadDir => app.config.passage_download_dir = value, PathField::ExportPath => app.settings_export_path = value, PathField::ImportPath => app.settings_import_path = value, } app.settings_editing_path = None; } InputResult::Cancel => { app.settings_editing_path = None; } InputResult::Continue => {} } return; } match key.code { KeyCode::Esc => { let _ = app.config.save(); app.go_to_menu(); } KeyCode::Up | KeyCode::Char('k') => { if app.settings_selected > 0 { app.settings_selected -= 1; } } KeyCode::Down | KeyCode::Char('j') => { if app.settings_selected < max_settings { app.settings_selected += 1; } } KeyCode::Enter => activate_settings_selected(app), KeyCode::Right | KeyCode::Char('l') => { if SettingItem::from_index(app.settings_selected).supports_left_right_cycle() { app.settings_cycle_forward(); } } KeyCode::Left | KeyCode::Char('h') => { if SettingItem::from_index(app.settings_selected).supports_left_right_cycle() { app.settings_cycle_backward(); } } _ => {} } } fn is_dictionary_language_disabled(_app: &App, language_key: &str) -> bool { find_language_pack(language_key).is_none() } fn dictionary_language_list_area(area: Rect) -> Rect { let centered = ui::layout::centered_rect(60, 70, area); let inner = Block::bordered().inner(centered); let h_nav = ui::hint::hint(ui::hint::K_UP_DOWN_PGUP_PGDN, t!("select.hint_navigate").as_ref()); let h_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("select.hint_confirm").as_ref()); let h_back = ui::hint::hint(ui::hint::K_Q_ESC, t!("select.hint_back").as_ref()); let hint_lines = pack_hint_lines( &[h_nav.as_str(), h_confirm.as_str(), h_back.as_str()], inner.width as usize, ); let footer_height = (hint_lines.len() as u16).max(1); if inner.height > footer_height { Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(footer_height)]) .split(inner)[0] } else { inner } } fn confirm_dictionary_language_selection(app: &mut App) { let options = language_packs(); if app.dictionary_language_selected >= options.len() { return; } let selected = options[app.dictionary_language_selected]; if is_dictionary_language_disabled(app, selected.language_key) { return; } match app.set_dictionary_language(selected.language_key) { Ok(CapabilityState::Enabled | CapabilityState::Disabled) => { app.settings_status_message = Some(app::StatusMessage { kind: StatusKind::Success, text: format!( "Switched to {}. Keyboard layout reset to {}", selected.display_name, app.config.keyboard_layout ), }); } Err(err) => { app.settings_status_message = Some(app::StatusMessage { kind: StatusKind::Error, text: err.to_string(), }); } } app.go_to_settings(); app.settings_selected = SettingItem::DictionaryLanguage.index(); } fn handle_dictionary_language_key(app: &mut App, key: KeyEvent) { let options = language_packs(); let len = options.len(); if len == 0 { return; } match key.code { KeyCode::Esc | KeyCode::Char('q') => { app.go_to_settings(); app.settings_selected = SettingItem::DictionaryLanguage.index(); } KeyCode::Up | KeyCode::Char('k') => { app.dictionary_language_selected = app.dictionary_language_selected.saturating_sub(1); } KeyCode::Down | KeyCode::Char('j') => { if app.dictionary_language_selected + 1 < len { app.dictionary_language_selected += 1; } } KeyCode::PageUp => { app.dictionary_language_selected = app.dictionary_language_selected.saturating_sub(10); } KeyCode::PageDown => { app.dictionary_language_selected = (app.dictionary_language_selected + 10).min(len - 1); } KeyCode::Home | KeyCode::Char('g') => { app.dictionary_language_selected = 0; } KeyCode::End | KeyCode::Char('G') => { app.dictionary_language_selected = len - 1; } KeyCode::Enter => confirm_dictionary_language_selection(app), KeyCode::Char(ch) if ('1'..='9').contains(&ch) => { let idx = (ch as usize) - ('1' as usize); if idx < len { app.dictionary_language_selected = idx; confirm_dictionary_language_selection(app); return; } } _ => {} } let viewport = 15usize; if app.dictionary_language_selected < app.dictionary_language_scroll { app.dictionary_language_scroll = app.dictionary_language_selected; } else if app.dictionary_language_selected >= app.dictionary_language_scroll + viewport { app.dictionary_language_scroll = app.dictionary_language_selected + 1 - viewport; } } fn handle_dictionary_language_mouse(app: &mut App, mouse: MouseEvent) { let options = language_packs(); if options.is_empty() { return; } match mouse.kind { MouseEventKind::ScrollUp => { app.dictionary_language_selected = app.dictionary_language_selected.saturating_sub(1); } MouseEventKind::ScrollDown => { if app.dictionary_language_selected + 1 < options.len() { app.dictionary_language_selected += 1; } } MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { let area = terminal_area(); let centered = ui::layout::centered_rect(60, 70, area); let inner = Block::bordered().inner(centered); let h_nav = ui::hint::hint(ui::hint::K_UP_DOWN_PGUP_PGDN, t!("select.hint_navigate").as_ref()); let h_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("select.hint_confirm").as_ref()); let h_back = ui::hint::hint(ui::hint::K_Q_ESC, t!("select.hint_back").as_ref()); let hints: Vec<&str> = vec![h_nav.as_str(), h_confirm.as_str(), h_back.as_str()]; let footer_h = pack_hint_lines(&hints, inner.width as usize).len().max(1) as u16; let chunks = if inner.height > footer_h { Some( Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(footer_h)]) .split(inner), ) } else { None }; if let Some(chunks) = &chunks && let Some(token) = hint_token_at(chunks[1], &hints, mouse.column, mouse.row) { match token.as_str() { "Enter" => confirm_dictionary_language_selection(app), "q/ESC" => { app.go_to_settings(); app.settings_selected = SettingItem::DictionaryLanguage.index(); } _ => {} } return; } let list_area = dictionary_language_list_area(area); if !point_in_rect(mouse.column, mouse.row, list_area) { return; } let viewport_height = (list_area.height as usize).saturating_sub(2).max(1); let scroll = app.dictionary_language_scroll; let visible_end = (scroll + viewport_height).min(options.len()); let line_offset = (mouse.row - list_area.y) as usize; if line_offset == 0 { return; } let idx = scroll + line_offset - 1; if idx < visible_end { let was_selected = idx == app.dictionary_language_selected; app.dictionary_language_selected = idx; if was_selected { confirm_dictionary_language_selection(app); return; } } } _ => {} } let viewport = 15usize; if app.dictionary_language_selected < app.dictionary_language_scroll { app.dictionary_language_scroll = app.dictionary_language_selected; } else if app.dictionary_language_selected >= app.dictionary_language_scroll + viewport { app.dictionary_language_scroll = app.dictionary_language_selected + 1 - viewport; } } // --- UI Language Select --- fn confirm_ui_language_selection(app: &mut App) { let locales = i18n::SUPPORTED_UI_LOCALES; if app.ui_language_selected >= locales.len() { return; } let selected = locales[app.ui_language_selected]; app.config.ui_language = selected.to_string(); i18n::set_ui_locale(selected); let _ = app.config.save(); app.go_to_settings(); app.settings_selected = SettingItem::UiLanguage.index(); } fn handle_ui_language_key(app: &mut App, key: KeyEvent) { let locales = i18n::SUPPORTED_UI_LOCALES; let len = locales.len(); if len == 0 { return; } match key.code { KeyCode::Esc | KeyCode::Char('q') => { app.go_to_settings(); app.settings_selected = SettingItem::UiLanguage.index(); } KeyCode::Up | KeyCode::Char('k') => { app.ui_language_selected = app.ui_language_selected.saturating_sub(1); } KeyCode::Down | KeyCode::Char('j') => { if app.ui_language_selected + 1 < len { app.ui_language_selected += 1; } } KeyCode::Enter => confirm_ui_language_selection(app), _ => {} } } fn handle_ui_language_mouse(app: &mut App, mouse: MouseEvent) { let locales = i18n::SUPPORTED_UI_LOCALES; if locales.is_empty() { return; } match mouse.kind { MouseEventKind::ScrollUp => { app.ui_language_selected = app.ui_language_selected.saturating_sub(1); } MouseEventKind::ScrollDown => { if app.ui_language_selected + 1 < locales.len() { app.ui_language_selected += 1; } } _ => {} } } // --- Keyboard Layout --- fn is_keyboard_layout_disabled(layout_key: &str) -> bool { dictionary_languages_for_layout(layout_key).is_empty() } fn keyboard_layout_list_area(area: Rect) -> Rect { let centered = ui::layout::centered_rect(60, 70, area); let inner = Block::bordered().inner(centered); let h_nav = ui::hint::hint(ui::hint::K_UP_DOWN_PGUP_PGDN, t!("select.hint_navigate").as_ref()); let h_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("select.hint_confirm").as_ref()); let h_back = ui::hint::hint(ui::hint::K_Q_ESC, t!("select.hint_back").as_ref()); let hint_lines = pack_hint_lines( &[h_nav.as_str(), h_confirm.as_str(), h_back.as_str()], inner.width as usize, ); let footer_height = (hint_lines.len() as u16).max(1); if inner.height > footer_height { Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(footer_height)]) .split(inner)[0] } else { inner } } fn confirm_keyboard_layout_selection(app: &mut App) { let options = keyboard::model::KeyboardModel::supported_layout_keys(); if app.keyboard_layout_selected >= options.len() { return; } let selected = options[app.keyboard_layout_selected]; if is_keyboard_layout_disabled(selected) { return; } match app.set_keyboard_layout(selected) { Ok(CapabilityState::Enabled | CapabilityState::Disabled) => {} Err(err) => { app.settings_status_message = Some(app::StatusMessage { kind: StatusKind::Error, text: err.to_string(), }); } } app.go_to_settings(); app.settings_selected = SettingItem::KeyboardLayout.index(); } fn handle_keyboard_layout_key(app: &mut App, key: KeyEvent) { let options = keyboard::model::KeyboardModel::supported_layout_keys(); let len = options.len(); if len == 0 { return; } match key.code { KeyCode::Esc | KeyCode::Char('q') => { app.go_to_settings(); app.settings_selected = SettingItem::KeyboardLayout.index(); } KeyCode::Up | KeyCode::Char('k') => { app.keyboard_layout_selected = app.keyboard_layout_selected.saturating_sub(1); } KeyCode::Down | KeyCode::Char('j') => { if app.keyboard_layout_selected + 1 < len { app.keyboard_layout_selected += 1; } } KeyCode::PageUp => { app.keyboard_layout_selected = app.keyboard_layout_selected.saturating_sub(10); } KeyCode::PageDown => { app.keyboard_layout_selected = (app.keyboard_layout_selected + 10).min(len - 1); } KeyCode::Home | KeyCode::Char('g') => app.keyboard_layout_selected = 0, KeyCode::End | KeyCode::Char('G') => app.keyboard_layout_selected = len - 1, KeyCode::Enter => confirm_keyboard_layout_selection(app), KeyCode::Char(ch) if ('1'..='9').contains(&ch) => { let idx = (ch as usize) - ('1' as usize); if idx < len { app.keyboard_layout_selected = idx; confirm_keyboard_layout_selection(app); return; } } _ => {} } let viewport = 15usize; if app.keyboard_layout_selected < app.keyboard_layout_scroll { app.keyboard_layout_scroll = app.keyboard_layout_selected; } else if app.keyboard_layout_selected >= app.keyboard_layout_scroll + viewport { app.keyboard_layout_scroll = app.keyboard_layout_selected + 1 - viewport; } } fn handle_keyboard_layout_mouse(app: &mut App, mouse: MouseEvent) { let options = keyboard::model::KeyboardModel::supported_layout_keys(); if options.is_empty() { return; } match mouse.kind { MouseEventKind::ScrollUp => { app.keyboard_layout_selected = app.keyboard_layout_selected.saturating_sub(1); } MouseEventKind::ScrollDown => { if app.keyboard_layout_selected + 1 < options.len() { app.keyboard_layout_selected += 1; } } MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { let area = terminal_area(); let centered = ui::layout::centered_rect(60, 70, area); let inner = Block::bordered().inner(centered); let h_nav = ui::hint::hint(ui::hint::K_UP_DOWN_PGUP_PGDN, t!("select.hint_navigate").as_ref()); let h_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("select.hint_confirm").as_ref()); let h_back = ui::hint::hint(ui::hint::K_Q_ESC, t!("select.hint_back").as_ref()); let hints: Vec<&str> = vec![h_nav.as_str(), h_confirm.as_str(), h_back.as_str()]; let footer_h = pack_hint_lines(&hints, inner.width as usize).len().max(1) as u16; let chunks = if inner.height > footer_h { Some( Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(footer_h)]) .split(inner), ) } else { None }; if let Some(chunks) = &chunks && let Some(token) = hint_token_at(chunks[1], &hints, mouse.column, mouse.row) { match token.as_str() { "Enter" => confirm_keyboard_layout_selection(app), "q/ESC" => { app.go_to_settings(); app.settings_selected = SettingItem::KeyboardLayout.index(); } _ => {} } return; } let list_area = keyboard_layout_list_area(area); if !point_in_rect(mouse.column, mouse.row, list_area) { return; } let viewport_height = (list_area.height as usize).saturating_sub(2).max(1); let scroll = app.keyboard_layout_scroll; let visible_end = (scroll + viewport_height).min(options.len()); let line_offset = (mouse.row - list_area.y) as usize; if line_offset == 0 { return; } let idx = scroll + line_offset - 1; if idx < visible_end { let was_selected = idx == app.keyboard_layout_selected; app.keyboard_layout_selected = idx; if was_selected { confirm_keyboard_layout_selection(app); return; } } } _ => {} } let viewport = 15usize; if app.keyboard_layout_selected < app.keyboard_layout_scroll { app.keyboard_layout_scroll = app.keyboard_layout_selected; } else if app.keyboard_layout_selected >= app.keyboard_layout_scroll + viewport { app.keyboard_layout_scroll = app.keyboard_layout_selected + 1 - viewport; } } fn handle_code_language_key(app: &mut App, key: KeyEvent) { let options = code_language_options(); let len = options.len(); if len == 0 { return; } match key.code { KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), KeyCode::Up | KeyCode::Char('k') => { app.code_language_selected = app.code_language_selected.saturating_sub(1); } KeyCode::Down | KeyCode::Char('j') => { if app.code_language_selected + 1 < len { app.code_language_selected += 1; } } KeyCode::PageUp => { app.code_language_selected = app.code_language_selected.saturating_sub(10); } KeyCode::PageDown => { app.code_language_selected = (app.code_language_selected + 10).min(len - 1); } KeyCode::Home | KeyCode::Char('g') => { app.code_language_selected = 0; } KeyCode::End | KeyCode::Char('G') => { app.code_language_selected = len - 1; } KeyCode::Enter => { if app.code_language_selected >= options.len() { return; } let key = options[app.code_language_selected].0; if !is_code_language_disabled(app, key) { confirm_code_language_and_continue(app, &options); } } _ => {} } // Adjust scroll to keep selected item visible. // Use a rough viewport estimate; render will use exact terminal size. let viewport = 15usize; if app.code_language_selected < app.code_language_scroll { app.code_language_scroll = app.code_language_selected; } else if app.code_language_selected >= app.code_language_scroll + viewport { app.code_language_scroll = app.code_language_selected + 1 - viewport; } } fn code_language_list_area(app: &App, area: Rect) -> Rect { let centered = ui::layout::centered_rect(50, 70, area); let inner = Block::bordered().inner(centered); let options = code_language_options(); let width = inner.width as usize; let h_nav = ui::hint::hint(ui::hint::K_UP_DOWN_PGUP_PGDN, t!("select.hint_navigate").as_ref()); let h_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("select.hint_confirm").as_ref()); let h_back = ui::hint::hint(ui::hint::K_Q_ESC, t!("select.hint_back").as_ref()); let hint_lines = pack_hint_lines( &[h_nav.as_str(), h_confirm.as_str(), h_back.as_str()], width, ); let disabled_notice_t = t!("select.disabled_network_notice"); let disabled_notice = disabled_notice_t.as_ref(); let has_disabled = !app.config.code_downloads_enabled && options .iter() .any(|(key, _)| is_code_language_disabled(app, key)); let notice_lines = wrapped_line_count(disabled_notice, width); let total_height = inner.height as usize; let show_notice = has_disabled && total_height >= hint_lines.len() + notice_lines + 3; let desired_footer_height = hint_lines.len() + if show_notice { notice_lines } else { 0 }; let footer_height = desired_footer_height.min(total_height.saturating_sub(1)) as u16; if footer_height > 0 { Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(footer_height)]) .split(inner)[0] } else { inner } } fn handle_code_language_mouse(app: &mut App, mouse: MouseEvent) { let options = code_language_options(); let len = options.len(); if len == 0 { return; } let is_secondary = matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)); match mouse.kind { MouseEventKind::ScrollUp => { app.code_language_selected = app.code_language_selected.saturating_sub(1); } MouseEventKind::ScrollDown => { if app.code_language_selected + 1 < len { app.code_language_selected += 1; } } MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { let centered = ui::layout::centered_rect(50, 70, terminal_area()); let inner = Block::bordered().inner(centered); let h_nav = ui::hint::hint(ui::hint::K_UP_DOWN_PGUP_PGDN, t!("select.hint_navigate").as_ref()); let h_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("select.hint_confirm").as_ref()); let h_back = ui::hint::hint(ui::hint::K_Q_ESC, t!("select.hint_back").as_ref()); let hints: Vec<&str> = vec![h_nav.as_str(), h_confirm.as_str(), h_back.as_str()]; let hint_lines = pack_hint_lines(&hints, inner.width as usize); let disabled_notice_t = t!("select.disabled_network_notice"); let disabled_notice = disabled_notice_t.as_ref(); let has_disabled = !app.config.code_downloads_enabled && options .iter() .any(|(key, _)| is_code_language_disabled(app, key)); let notice_lines = wrapped_line_count(disabled_notice, inner.width as usize); let total_height = inner.height as usize; let show_notice = has_disabled && total_height >= hint_lines.len() + notice_lines + 3; let desired_footer_height = hint_lines.len() + if show_notice { notice_lines } else { 0 }; let footer_height = desired_footer_height.min(total_height.saturating_sub(1)) as u16; if footer_height > 0 { let footer_area = Rect::new( inner.x, inner.y + inner.height.saturating_sub(footer_height), inner.width, footer_height, ); if let Some(token) = hint_token_at(footer_area, &hints, mouse.column, mouse.row) { match token.as_str() { "Up/Down/PgUp/PgDn" => { if is_secondary { app.code_language_selected = app.code_language_selected.saturating_sub(1); } else if app.code_language_selected + 1 < len { app.code_language_selected += 1; } } "Enter" => { handle_code_language_key( app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); } "q/ESC" => app.go_to_menu(), _ => {} } return; } } let list_area = code_language_list_area(app, terminal_area()); if !point_in_rect(mouse.column, mouse.row, list_area) { return; } let viewport_height = (list_area.height as usize).saturating_sub(2).max(1); let scroll = app.code_language_scroll; let visible_end = (scroll + viewport_height).min(len); let line_offset = (mouse.row - list_area.y) as usize; if line_offset == 0 { return; } let idx = scroll + line_offset - 1; if idx < visible_end { let selected_before = app.code_language_selected; app.code_language_selected = idx; let key = options[idx].0; if selected_before == idx && !is_code_language_disabled(app, key) { confirm_code_language_and_continue(app, &options); return; } } } _ => {} } let viewport = 15usize; if app.code_language_selected < app.code_language_scroll { app.code_language_scroll = app.code_language_selected; } else if app.code_language_selected >= app.code_language_scroll + viewport { app.code_language_scroll = app.code_language_selected + 1 - viewport; } } fn code_language_requires_download(app: &App, key: &str) -> bool { if key == "all" { return false; } let Some(lang) = language_by_key(key) else { return false; }; !lang.has_builtin && !is_language_cached(&app.config.code_download_dir, key) } fn is_code_language_disabled(app: &App, key: &str) -> bool { !app.config.code_downloads_enabled && code_language_requires_download(app, key) } fn confirm_code_language_and_continue(app: &mut App, options: &[(&str, String)]) { if app.code_language_selected >= options.len() { return; } app.config.code_language = options[app.code_language_selected].0.to_string(); let _ = app.config.save(); if app.config.code_onboarding_done { app.start_code_drill(); } else { app.go_to_code_intro(); } } fn handle_passage_book_key(app: &mut App, key: KeyEvent) { let options = passage_options(); match key.code { KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), KeyCode::Up | KeyCode::Char('k') => { app.passage_book_selected = app.passage_book_selected.saturating_sub(1); } KeyCode::Down | KeyCode::Char('j') => { if app.passage_book_selected + 1 < options.len() { app.passage_book_selected += 1; } } KeyCode::Char(ch) if ch.is_ascii_digit() => { let idx = (ch as usize).saturating_sub('1' as usize); if idx < options.len() { app.passage_book_selected = idx; let key = options[idx].0; if !is_passage_option_disabled(app, key) { confirm_passage_book_and_continue(app, &options); } } } KeyCode::Enter => { if app.passage_book_selected < options.len() { let key = options[app.passage_book_selected].0; if !is_passage_option_disabled(app, key) { confirm_passage_book_and_continue(app, &options); } } } _ => {} } } fn passage_book_list_area(app: &App, area: Rect) -> Rect { let centered = ui::layout::centered_rect(60, 70, area); let inner = Block::bordered().inner(centered); let options = passage_options(); let width = inner.width as usize; let h_nav_t = ui::hint::hint(ui::hint::K_UP_DOWN, t!("select.hint_navigate").as_ref()); let h_confirm_t = ui::hint::hint(ui::hint::K_ENTER, t!("select.hint_confirm").as_ref()); let h_back_t = ui::hint::hint(ui::hint::K_Q_ESC, t!("select.hint_back").as_ref()); let hint_lines = pack_hint_lines( &[h_nav_t.as_str(), h_confirm_t.as_str(), h_back_t.as_str()], width, ); let disabled_notice_t = t!("select.disabled_sources_notice"); let disabled_notice = disabled_notice_t.as_ref(); let has_disabled = !app.config.passage_downloads_enabled && options .iter() .any(|(key, _)| is_passage_option_disabled(app, key)); let notice_lines = wrapped_line_count(disabled_notice, width); let total_height = inner.height as usize; let show_notice = has_disabled && total_height >= hint_lines.len() + notice_lines + 3; let desired_footer_height = hint_lines.len() + if show_notice { notice_lines } else { 0 }; let footer_height = desired_footer_height.min(total_height.saturating_sub(1)) as u16; if footer_height > 0 { Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(footer_height)]) .split(inner)[0] } else { inner } } fn handle_passage_book_mouse(app: &mut App, mouse: MouseEvent) { let options = passage_options(); if options.is_empty() { return; } let is_secondary = matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)); match mouse.kind { MouseEventKind::ScrollUp => { app.passage_book_selected = app.passage_book_selected.saturating_sub(1); } MouseEventKind::ScrollDown => { if app.passage_book_selected + 1 < options.len() { app.passage_book_selected += 1; } } MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { let centered = ui::layout::centered_rect(60, 70, terminal_area()); let inner = Block::bordered().inner(centered); let h_nav = ui::hint::hint(ui::hint::K_UP_DOWN, t!("select.hint_navigate").as_ref()); let h_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("select.hint_confirm").as_ref()); let h_back = ui::hint::hint(ui::hint::K_Q_ESC, t!("select.hint_back").as_ref()); let hints: Vec<&str> = vec![h_nav.as_str(), h_confirm.as_str(), h_back.as_str()]; let hint_lines = pack_hint_lines(&hints, inner.width as usize); let disabled_notice_t = t!("select.disabled_sources_notice"); let disabled_notice = disabled_notice_t.as_ref(); let has_disabled = !app.config.passage_downloads_enabled && options .iter() .any(|(key, _)| is_passage_option_disabled(app, key)); let notice_lines = wrapped_line_count(disabled_notice, inner.width as usize); let total_height = inner.height as usize; let show_notice = has_disabled && total_height >= hint_lines.len() + notice_lines + 3; let desired_footer_height = hint_lines.len() + if show_notice { notice_lines } else { 0 }; let footer_height = desired_footer_height.min(total_height.saturating_sub(1)) as u16; if footer_height > 0 { let footer_area = Rect::new( inner.x, inner.y + inner.height.saturating_sub(footer_height), inner.width, footer_height, ); if let Some(token) = hint_token_at(footer_area, &hints, mouse.column, mouse.row) { match token.as_str() { "Up/Down" => { if is_secondary { app.passage_book_selected = app.passage_book_selected.saturating_sub(1); } else if app.passage_book_selected + 1 < options.len() { app.passage_book_selected += 1; } } "Enter" => handle_passage_book_key( app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ), "q/ESC" => app.go_to_menu(), _ => {} } return; } } let list_area = passage_book_list_area(app, terminal_area()); if !point_in_rect(mouse.column, mouse.row, list_area) { return; } let viewport_height = list_area.height as usize; let start = app .passage_book_selected .saturating_sub(viewport_height.saturating_sub(1)); let row = (mouse.row - list_area.y) as usize; let idx = start + row; if idx < options.len() { let selected_before = app.passage_book_selected; app.passage_book_selected = idx; let key = options[idx].0; if selected_before == idx && !is_passage_option_disabled(app, key) { confirm_passage_book_and_continue(app, &options); } } } _ => {} } } fn passage_option_requires_download(app: &App, key: &str) -> bool { key != "all" && key != "builtin" && !is_book_cached(&app.config.passage_download_dir, key) } fn is_passage_option_disabled(app: &App, key: &str) -> bool { !app.config.passage_downloads_enabled && passage_option_requires_download(app, key) } fn confirm_passage_book_and_continue(app: &mut App, options: &[(&'static str, String)]) { if app.passage_book_selected >= options.len() { return; } app.config.passage_book = options[app.passage_book_selected].0.to_string(); let _ = app.config.save(); if app.config.passage_onboarding_done { app.start_passage_drill(); } else { app.go_to_passage_intro(); } } fn handle_passage_intro_key(app: &mut App, key: KeyEvent) { const INTRO_FIELDS: usize = 4; if app.passage_intro_downloading { return; } match key.code { KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), KeyCode::Up | KeyCode::Char('k') => { app.passage_intro_selected = app.passage_intro_selected.saturating_sub(1); } KeyCode::Down | KeyCode::Char('j') => { if app.passage_intro_selected + 1 < INTRO_FIELDS { app.passage_intro_selected += 1; } } KeyCode::Left | KeyCode::Char('h') => match app.passage_intro_selected { 0 => app.passage_intro_downloads_enabled = !app.passage_intro_downloads_enabled, 2 => { app.passage_intro_paragraph_limit = match app.passage_intro_paragraph_limit { 0 => 500, 1 => 0, n => n.saturating_sub(25).max(1), }; } _ => {} }, KeyCode::Right | KeyCode::Char('l') => match app.passage_intro_selected { 0 => app.passage_intro_downloads_enabled = !app.passage_intro_downloads_enabled, 2 => { app.passage_intro_paragraph_limit = match app.passage_intro_paragraph_limit { 0 => 1, n if n >= 500 => 0, n => n + 25, }; } _ => {} }, KeyCode::Backspace => match app.passage_intro_selected { 1 => { app.passage_intro_download_dir.pop(); } 2 => { app.passage_intro_paragraph_limit /= 10; } _ => {} }, KeyCode::Char(ch) => match app.passage_intro_selected { 1 if !key.modifiers.contains(KeyModifiers::CONTROL) => { app.passage_intro_download_dir.push(ch); } 2 if ch.is_ascii_digit() => { let digit = (ch as u8 - b'0') as usize; app.passage_intro_paragraph_limit = app .passage_intro_paragraph_limit .saturating_mul(10) .saturating_add(digit) .min(50_000); } _ => {} }, KeyCode::Enter => { if app.passage_intro_selected == 0 { app.passage_intro_downloads_enabled = !app.passage_intro_downloads_enabled; return; } if app.passage_intro_selected != 3 { return; } app.config.passage_downloads_enabled = app.passage_intro_downloads_enabled; app.config.passage_download_dir = app.passage_intro_download_dir.clone(); app.config.passage_paragraphs_per_book = app.passage_intro_paragraph_limit; app.config.passage_onboarding_done = true; let _ = app.config.save(); app.go_to_passage_book_select(); } _ => {} } } fn intro_field_at_row(base_y: u16, y: u16) -> Option<(usize, bool)> { if y < base_y { return None; } let rel = y - base_y; let field = (rel / 3) as usize; if field >= 4 { return None; } let value_row = rel % 3 == 1; Some((field, value_row)) } fn passage_intro_content_area(area: Rect) -> Rect { let centered = ui::layout::centered_rect(75, 80, area); let inner = Block::bordered().inner(centered); let ih_nav = ui::hint::hint(ui::hint::K_UP_DOWN, t!("intro.hint_navigate").as_ref()); let ih_adj = ui::hint::hint(ui::hint::K_LEFT_RIGHT, t!("intro.hint_adjust").as_ref()); let ih_edit = ui::hint::hint(ui::hint::K_TYPE_BACKSPACE, t!("intro.hint_edit").as_ref()); let ih_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("intro.hint_confirm").as_ref()); let ih_cancel = ui::hint::hint(ui::hint::K_Q_ESC, t!("intro.hint_cancel").as_ref()); let hint_lines = pack_hint_lines( &[ih_nav.as_str(), ih_adj.as_str(), ih_edit.as_str(), ih_confirm.as_str(), ih_cancel.as_str()], inner.width as usize, ); let footer_height = (hint_lines.len() + 1) as u16; if footer_height > 0 && footer_height < inner.height { Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(footer_height)]) .split(inner)[0] } else { inner } } fn handle_passage_intro_mouse(app: &mut App, mouse: MouseEvent) { if app.passage_intro_downloading { return; } let is_secondary = matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)); match mouse.kind { MouseEventKind::ScrollUp => { app.passage_intro_selected = app.passage_intro_selected.saturating_sub(1); } MouseEventKind::ScrollDown => { app.passage_intro_selected = (app.passage_intro_selected + 1).min(3); } MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { let centered = ui::layout::centered_rect(75, 80, terminal_area()); let inner = Block::bordered().inner(centered); let ih_nav = ui::hint::hint(ui::hint::K_UP_DOWN, t!("intro.hint_navigate").as_ref()); let ih_adj = ui::hint::hint(ui::hint::K_LEFT_RIGHT, t!("intro.hint_adjust").as_ref()); let ih_edit = ui::hint::hint(ui::hint::K_TYPE_BACKSPACE, t!("intro.hint_edit").as_ref()); let ih_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("intro.hint_confirm").as_ref()); let ih_cancel = ui::hint::hint(ui::hint::K_Q_ESC, t!("intro.hint_cancel").as_ref()); let hints: Vec<&str> = vec![ih_nav.as_str(), ih_adj.as_str(), ih_edit.as_str(), ih_confirm.as_str(), ih_cancel.as_str()]; let hint_lines = pack_hint_lines(&hints, inner.width as usize); let footer_height = (hint_lines.len() + 1) as u16; if footer_height > 0 && footer_height < inner.height { let footer_area = Rect::new( inner.x, inner.y + inner.height.saturating_sub(footer_height), inner.width, footer_height, ); if let Some(token) = hint_token_at(footer_area, &hints, mouse.column, mouse.row) { match token.as_str() { "Up/Down" => { if is_secondary { app.passage_intro_selected = app.passage_intro_selected.saturating_sub(1); } else { app.passage_intro_selected = (app.passage_intro_selected + 1).min(3); } } "Left/Right" => { handle_passage_intro_key( app, KeyEvent::new( if is_secondary { KeyCode::Right } else { KeyCode::Left }, KeyModifiers::NONE, ), ); } "Type/Backspace" => { if is_secondary { handle_passage_intro_key( app, KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), ); } } "Enter" => handle_passage_intro_key( app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ), "q/ESC" => app.go_to_menu(), _ => {} } return; } } let content = passage_intro_content_area(terminal_area()); if !point_in_rect(mouse.column, mouse.row, content) { return; } let base_y = content.y.saturating_add(4); if let Some((field, value_row)) = intro_field_at_row(base_y, mouse.row) { let was_selected = app.passage_intro_selected == field; app.passage_intro_selected = field; if field == 0 && (value_row || was_selected) { app.passage_intro_downloads_enabled = !app.passage_intro_downloads_enabled; } else if field == 3 && (value_row || was_selected) { handle_passage_intro_key( app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); } } } _ => {} } } fn handle_passage_download_progress_key(app: &mut App, key: KeyEvent) { match key.code { KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), _ => {} } } fn handle_passage_download_progress_mouse(app: &mut App, mouse: MouseEvent) { if matches!( mouse.kind, MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) ) { app.go_to_menu(); } } fn handle_code_intro_key(app: &mut App, key: KeyEvent) { const INTRO_FIELDS: usize = 4; if app.code_intro_downloading { return; } match key.code { KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), KeyCode::Up | KeyCode::Char('k') => { app.code_intro_selected = app.code_intro_selected.saturating_sub(1); } KeyCode::Down | KeyCode::Char('j') => { if app.code_intro_selected + 1 < INTRO_FIELDS { app.code_intro_selected += 1; } } KeyCode::Left | KeyCode::Char('h') => match app.code_intro_selected { 0 => app.code_intro_downloads_enabled = !app.code_intro_downloads_enabled, 2 => { app.code_intro_snippets_per_repo = match app.code_intro_snippets_per_repo { 0 => 200, 1 => 0, n => n.saturating_sub(10).max(1), }; } _ => {} }, KeyCode::Right | KeyCode::Char('l') => match app.code_intro_selected { 0 => app.code_intro_downloads_enabled = !app.code_intro_downloads_enabled, 2 => { app.code_intro_snippets_per_repo = match app.code_intro_snippets_per_repo { 0 => 1, n if n >= 200 => 0, n => n + 10, }; } _ => {} }, KeyCode::Backspace => match app.code_intro_selected { 1 => { app.code_intro_download_dir.pop(); } 2 => { app.code_intro_snippets_per_repo /= 10; } _ => {} }, KeyCode::Char(ch) => match app.code_intro_selected { 1 if !key.modifiers.contains(KeyModifiers::CONTROL) => { app.code_intro_download_dir.push(ch); } 2 if ch.is_ascii_digit() => { let digit = (ch as u8 - b'0') as usize; app.code_intro_snippets_per_repo = app .code_intro_snippets_per_repo .saturating_mul(10) .saturating_add(digit) .min(10_000); } _ => {} }, KeyCode::Enter => { if app.code_intro_selected == 0 { app.code_intro_downloads_enabled = !app.code_intro_downloads_enabled; return; } if app.code_intro_selected != 3 { return; } app.config.code_downloads_enabled = app.code_intro_downloads_enabled; app.config.code_download_dir = app.code_intro_download_dir.clone(); app.config.code_snippets_per_repo = app.code_intro_snippets_per_repo; app.config.code_onboarding_done = true; let _ = app.config.save(); app.go_to_code_language_select(); } _ => {} } } fn code_intro_content_area(area: Rect) -> Rect { let centered = ui::layout::centered_rect(75, 80, area); let inner = Block::bordered().inner(centered); let ih_nav = ui::hint::hint(ui::hint::K_UP_DOWN, t!("intro.hint_navigate").as_ref()); let ih_adj = ui::hint::hint(ui::hint::K_LEFT_RIGHT, t!("intro.hint_adjust").as_ref()); let ih_edit = ui::hint::hint(ui::hint::K_TYPE_BACKSPACE, t!("intro.hint_edit").as_ref()); let ih_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("intro.hint_confirm").as_ref()); let ih_cancel = ui::hint::hint(ui::hint::K_Q_ESC, t!("intro.hint_cancel").as_ref()); let hint_lines = pack_hint_lines( &[ih_nav.as_str(), ih_adj.as_str(), ih_edit.as_str(), ih_confirm.as_str(), ih_cancel.as_str()], inner.width as usize, ); let footer_height = (hint_lines.len() + 1) as u16; if footer_height > 0 && footer_height < inner.height { Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(footer_height)]) .split(inner)[0] } else { inner } } fn handle_code_intro_mouse(app: &mut App, mouse: MouseEvent) { if app.code_intro_downloading { return; } let is_secondary = matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)); match mouse.kind { MouseEventKind::ScrollUp => { app.code_intro_selected = app.code_intro_selected.saturating_sub(1); } MouseEventKind::ScrollDown => { app.code_intro_selected = (app.code_intro_selected + 1).min(3); } MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { let centered = ui::layout::centered_rect(75, 80, terminal_area()); let inner = Block::bordered().inner(centered); let ih_nav = ui::hint::hint(ui::hint::K_UP_DOWN, t!("intro.hint_navigate").as_ref()); let ih_adj = ui::hint::hint(ui::hint::K_LEFT_RIGHT, t!("intro.hint_adjust").as_ref()); let ih_edit = ui::hint::hint(ui::hint::K_TYPE_BACKSPACE, t!("intro.hint_edit").as_ref()); let ih_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("intro.hint_confirm").as_ref()); let ih_cancel = ui::hint::hint(ui::hint::K_Q_ESC, t!("intro.hint_cancel").as_ref()); let hints: Vec<&str> = vec![ih_nav.as_str(), ih_adj.as_str(), ih_edit.as_str(), ih_confirm.as_str(), ih_cancel.as_str()]; let hint_lines = pack_hint_lines(&hints, inner.width as usize); let footer_height = (hint_lines.len() + 1) as u16; if footer_height > 0 && footer_height < inner.height { let footer_area = Rect::new( inner.x, inner.y + inner.height.saturating_sub(footer_height), inner.width, footer_height, ); if let Some(token) = hint_token_at(footer_area, &hints, mouse.column, mouse.row) { match token.as_str() { "Up/Down" => { if is_secondary { app.code_intro_selected = app.code_intro_selected.saturating_sub(1); } else { app.code_intro_selected = (app.code_intro_selected + 1).min(3); } } "Left/Right" => { handle_code_intro_key( app, KeyEvent::new( if is_secondary { KeyCode::Right } else { KeyCode::Left }, KeyModifiers::NONE, ), ); } "Type/Backspace" => { if is_secondary { handle_code_intro_key( app, KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), ); } } "Enter" => handle_code_intro_key( app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ), "q/ESC" => app.go_to_menu(), _ => {} } return; } } let content = code_intro_content_area(terminal_area()); if !point_in_rect(mouse.column, mouse.row, content) { return; } let base_y = content.y.saturating_add(4); if let Some((field, value_row)) = intro_field_at_row(base_y, mouse.row) { let was_selected = app.code_intro_selected == field; app.code_intro_selected = field; if field == 0 && (value_row || was_selected) { app.code_intro_downloads_enabled = !app.code_intro_downloads_enabled; } else if field == 3 && (value_row || was_selected) { handle_code_intro_key(app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); } } } _ => {} } } fn handle_code_download_progress_key(app: &mut App, key: KeyEvent) { match key.code { KeyCode::Esc | KeyCode::Char('q') => { app.cancel_code_download(); app.go_to_menu(); } _ => {} } } fn handle_code_download_progress_mouse(app: &mut App, mouse: MouseEvent) { if matches!( mouse.kind, MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) ) { app.cancel_code_download(); app.go_to_menu(); } } 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(); match key.code { KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), KeyCode::Up | KeyCode::Char('k') => { app.skill_tree_selected = app.skill_tree_selected.saturating_sub(1); app.skill_tree_detail_scroll = 0; } KeyCode::Down | KeyCode::Char('j') => { if app.skill_tree_selected + 1 < branches.len() { app.skill_tree_selected += 1; app.skill_tree_detail_scroll = 0; } } KeyCode::PageUp => { app.skill_tree_detail_scroll = app .skill_tree_detail_scroll .saturating_sub(DETAIL_SCROLL_STEP); } KeyCode::PageDown => { let max_scroll = skill_tree_detail_max_scroll(app); app.skill_tree_detail_scroll = app .skill_tree_detail_scroll .saturating_add(DETAIL_SCROLL_STEP) .min(max_scroll); } KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { app.skill_tree_detail_scroll = app .skill_tree_detail_scroll .saturating_sub(DETAIL_SCROLL_STEP); } KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { let max_scroll = skill_tree_detail_max_scroll(app); app.skill_tree_detail_scroll = app .skill_tree_detail_scroll .saturating_add(DETAIL_SCROLL_STEP) .min(max_scroll); } KeyCode::Enter => { 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 { app.skill_tree_confirm_unlock = Some(branch_id); } else if status == engine::skill_tree::BranchStatus::InProgress { app.start_branch_drill(branch_id); } } } _ => {} } } struct SkillTreeMouseLayout { branch_area: Rect, detail_area: Rect, inter_branch_spacing: bool, separator_padding: bool, } fn locked_branch_notice(app: &App) -> String { t!("skill_tree.locked_notice", count = app.skill_tree.primary_letters().len()).to_string() } fn skill_tree_interactive_areas(app: &App, area: Rect) -> SkillTreeMouseLayout { let centered = skill_tree_popup_rect(area); let inner = Block::bordered().inner(centered); let branches = selectable_branches(); let selected = app .skill_tree_selected .min(branches.len().saturating_sub(1)); let bp = branches .get(selected) .map(|id| app.skill_tree.branch_progress(*id)); let st_nav = ui::hint::hint(ui::hint::K_UD_JK, t!("skill_tree.hint_navigate").as_ref()); let st_scroll = ui::hint::hint(ui::hint::K_SCROLL_KEYS, t!("skill_tree.hint_scroll").as_ref()); let st_back = ui::hint::hint(ui::hint::K_Q_ESC, t!("skill_tree.hint_back").as_ref()); let st_unlock = ui::hint::hint(ui::hint::K_ENTER, t!("skill_tree.hint_unlock").as_ref()); let st_drill = ui::hint::hint(ui::hint::K_ENTER, t!("skill_tree.hint_start_drill").as_ref()); let (footer_hints, footer_notice): (Vec<&str>, Option) = match bp.map(|b| b.status.clone()) { Some(BranchStatus::Locked) => ( vec![ st_nav.as_str(), st_scroll.as_str(), st_back.as_str(), ], Some(locked_branch_notice(app)), ), Some(BranchStatus::Available) => ( vec![ st_unlock.as_str(), st_nav.as_str(), st_scroll.as_str(), st_back.as_str(), ], None, ), Some(BranchStatus::InProgress) => ( vec![ st_drill.as_str(), st_nav.as_str(), st_scroll.as_str(), st_back.as_str(), ], None, ), _ => ( vec![ st_nav.as_str(), st_scroll.as_str(), st_back.as_str(), ], None, ), }; let hint_lines = pack_hint_lines(&footer_hints, inner.width as usize); let notice_lines = footer_notice .as_deref() .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); 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]); let (inter_branch_spacing, separator_padding) = branch_list_spacing_flags(main[0].height, branches.len()); SkillTreeMouseLayout { branch_area: main[0], detail_area: main[2], inter_branch_spacing, separator_padding, } } 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]); SkillTreeMouseLayout { branch_area: main[0], detail_area: main[2], inter_branch_spacing: false, separator_padding: false, } } } fn skill_tree_branch_index_from_y( branch_area: Rect, y: u16, branch_count: usize, inter_branch_spacing: bool, separator_padding: bool, ) -> Option { if y < branch_area.y || y >= branch_area.y + branch_area.height { return None; } let rel_y = (y - branch_area.y) as usize; let mut line = 0usize; for idx in 0..branch_count { if idx > 0 && inter_branch_spacing { line += 1; } let title_line = line; let progress_line = line + 1; if rel_y == title_line || rel_y == progress_line { return Some(idx); } line += 2; if idx == 0 { if separator_padding { line += 1; } line += 1; if separator_padding && !inter_branch_spacing { line += 1; } } } None } fn handle_skill_tree_mouse(app: &mut App, mouse: MouseEvent) { const DETAIL_SCROLL_STEP: usize = 3; if let Some(branch_id) = app.skill_tree_confirm_unlock { if matches!( mouse.kind, MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) ) { let area = terminal_area(); let dialog_width = 72u16.min(area.width.saturating_sub(4)); 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 content_width = dialog_width.saturating_sub(6).max(1) as usize; let body_required = 5 + wrapped_line_count(sentence_one, content_width) + wrapped_line_count(sentence_two, content_width); let min_dialog_height = (body_required + 1 + 2) as u16; let preferred_dialog_height = (body_required + 2 + 2) as u16; 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 = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height); if point_in_rect(mouse.column, mouse.row, dialog) { if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) || mouse.column >= dialog.x + dialog.width / 2 { // right-click (or right half) maps to "No" } else { app.unlock_branch(branch_id); } app.skill_tree_confirm_unlock = None; } } return; } match mouse.kind { MouseEventKind::ScrollUp => { app.skill_tree_detail_scroll = app .skill_tree_detail_scroll .saturating_sub(DETAIL_SCROLL_STEP); } MouseEventKind::ScrollDown => { let max_scroll = skill_tree_detail_max_scroll(app); app.skill_tree_detail_scroll = app .skill_tree_detail_scroll .saturating_add(DETAIL_SCROLL_STEP) .min(max_scroll); } MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { let is_secondary = matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)); let screen = terminal_area(); let centered = skill_tree_popup_rect(screen); let inner = Block::bordered().inner(centered); let branches = selectable_branches(); let selected = app .skill_tree_selected .min(branches.len().saturating_sub(1)); let bp = app.skill_tree.branch_progress(branches[selected]); let st_nav = ui::hint::hint(ui::hint::K_UD_JK, t!("skill_tree.hint_navigate").as_ref()); let st_scroll = ui::hint::hint(ui::hint::K_SCROLL_KEYS, t!("skill_tree.hint_scroll").as_ref()); let st_back = ui::hint::hint(ui::hint::K_Q_ESC, t!("skill_tree.hint_back").as_ref()); let st_unlock = ui::hint::hint(ui::hint::K_ENTER, t!("skill_tree.hint_unlock").as_ref()); let st_drill = ui::hint::hint(ui::hint::K_ENTER, t!("skill_tree.hint_start_drill").as_ref()); let (footer_hints, footer_notice): (Vec<&str>, Option) = if *app.skill_tree.branch_status(branches[selected]) == engine::skill_tree::BranchStatus::Locked { ( vec![ st_nav.as_str(), st_scroll.as_str(), st_back.as_str(), ], Some(locked_branch_notice(app)), ) } else if bp.status == engine::skill_tree::BranchStatus::Available { ( vec![ st_unlock.as_str(), st_nav.as_str(), st_scroll.as_str(), st_back.as_str(), ], None, ) } else if bp.status == engine::skill_tree::BranchStatus::InProgress { ( vec![ st_drill.as_str(), st_nav.as_str(), st_scroll.as_str(), st_back.as_str(), ], None, ) } else { ( vec![ st_nav.as_str(), st_scroll.as_str(), st_back.as_str(), ], None, ) }; let hint_lines = pack_hint_lines(&footer_hints, inner.width as usize); let notice_lines = footer_notice .as_deref() .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 footer_area = Rect::new( inner.x, inner.y + inner.height.saturating_sub(footer_height), inner.width, footer_height, ); if let Some(token) = hint_token_at(footer_area, &footer_hints, mouse.column, mouse.row) { match token.as_str() { "q/ESC" => app.go_to_menu(), "Enter" => { let branch_id = branches[selected]; let status = app.skill_tree.branch_status(branch_id).clone(); if status == BranchStatus::Available { app.skill_tree_confirm_unlock = Some(branch_id); } else if status == BranchStatus::InProgress { app.start_branch_drill(branch_id); } } "↑↓/jk" => { if is_secondary { if app.skill_tree_selected + 1 < branches.len() { app.skill_tree_selected += 1; } } else { app.skill_tree_selected = app.skill_tree_selected.saturating_sub(1); } app.skill_tree_detail_scroll = 0; } "PgUp/PgDn or Ctrl+U/Ctrl+D" => { let max_scroll = skill_tree_detail_max_scroll(app); app.skill_tree_detail_scroll = if is_secondary { app.skill_tree_detail_scroll .saturating_add(DETAIL_SCROLL_STEP) .min(max_scroll) } else { app.skill_tree_detail_scroll .saturating_sub(DETAIL_SCROLL_STEP) }; } _ => {} } return; } let branches = selectable_branches(); let layout = skill_tree_interactive_areas(app, terminal_area()); if point_in_rect(mouse.column, mouse.row, layout.branch_area) { if let Some(idx) = skill_tree_branch_index_from_y( layout.branch_area, mouse.row, branches.len(), layout.inter_branch_spacing, layout.separator_padding, ) { let already_selected = idx == app.skill_tree_selected; app.skill_tree_selected = idx; app.skill_tree_detail_scroll = 0; if already_selected { let branch_id = branches[idx]; let status = app.skill_tree.branch_status(branch_id).clone(); if status == BranchStatus::Available { app.skill_tree_confirm_unlock = Some(branch_id); } else if status == BranchStatus::InProgress { app.start_branch_drill(branch_id); } } } } else if point_in_rect(mouse.column, mouse.row, layout.detail_area) { // Click in detail pane focuses selected branch; scroll wheel handles movement. let _ = layout.detail_area; } } _ => {} } } fn skill_tree_detail_max_scroll(app: &App) -> usize { let (w, h) = crossterm::terminal::size().unwrap_or((120, 40)); let screen = Rect::new(0, 0, w, h); let centered = skill_tree_popup_rect(screen); let inner = Rect::new( centered.x.saturating_add(1), centered.y.saturating_add(1), centered.width.saturating_sub(2), centered.height.saturating_sub(2), ); let branches = selectable_branches(); if branches.is_empty() { return 0; } let selected = app .skill_tree_selected .min(branches.len().saturating_sub(1)); let bp = app.skill_tree.branch_progress(branches[selected]); let st_nav = ui::hint::hint(ui::hint::K_UD_JK, t!("skill_tree.hint_navigate").as_ref()); let st_scroll = ui::hint::hint(ui::hint::K_SCROLL_KEYS, t!("skill_tree.hint_scroll").as_ref()); let st_back = ui::hint::hint(ui::hint::K_Q_ESC, t!("skill_tree.hint_back").as_ref()); let st_unlock = ui::hint::hint(ui::hint::K_ENTER, t!("skill_tree.hint_unlock").as_ref()); let st_drill = ui::hint::hint(ui::hint::K_ENTER, t!("skill_tree.hint_start_drill").as_ref()); let (footer_hints, footer_notice): (Vec<&str>, Option) = if *app.skill_tree.branch_status(branches[selected]) == engine::skill_tree::BranchStatus::Locked { ( vec![ st_nav.as_str(), st_scroll.as_str(), st_back.as_str(), ], Some(locked_branch_notice(app)), ) } else if bp.status == engine::skill_tree::BranchStatus::Available { ( vec![ st_unlock.as_str(), st_nav.as_str(), st_scroll.as_str(), st_back.as_str(), ], None, ) } else if bp.status == engine::skill_tree::BranchStatus::InProgress { ( vec![ st_drill.as_str(), st_nav.as_str(), st_scroll.as_str(), st_back.as_str(), ], None, ) } else { ( vec![ st_nav.as_str(), st_scroll.as_str(), st_back.as_str(), ], None, ) }; let hint_lines = pack_hint_lines(&footer_hints, inner.width as usize); let notice_lines = footer_notice .as_deref() .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_for_tree( &app.skill_tree, detail_height as u16, branches[selected], ); let total_lines = detail_line_count_with_level_spacing_for_tree( &app.skill_tree, branches[selected], expanded, ); total_lines.saturating_sub(detail_height) } fn skill_tree_popup_rect(area: Rect) -> Rect { let percent_x = if area.width < 120 { 95 } else { 85 }; let percent_y = if area.height < 40 { 95 } else { 90 }; ui::layout::centered_rect(percent_x, percent_y, area) } fn render(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; let bg = Block::default().style(Style::default().bg(colors.bg())); frame.render_widget(bg, area); // Adaptive intro overlay for first-time adaptive drill users. if app.show_adaptive_intro { render_adaptive_intro_overlay(frame, app); return; } // Milestone overlays are modal and shown before the underlying screen. if let Some(milestone) = app.milestone_queue.front() { render_milestone_overlay(frame, app, milestone); return; } match app.screen { AppScreen::Menu => render_menu(frame, app), AppScreen::Drill => render_drill(frame, app), AppScreen::DrillResult => render_result(frame, app), AppScreen::StatsDashboard => render_stats(frame, app), AppScreen::Settings => render_settings(frame, app), AppScreen::DictionaryLanguageSelect => render_dictionary_language_select(frame, app), AppScreen::KeyboardLayoutSelect => render_keyboard_layout_select(frame, app), AppScreen::SkillTree => render_skill_tree(frame, app), AppScreen::CodeLanguageSelect => render_code_language_select(frame, app), AppScreen::PassageBookSelect => render_passage_book_select(frame, app), AppScreen::PassageIntro => render_passage_intro(frame, app), AppScreen::PassageDownloadProgress => render_passage_download_progress(frame, app), AppScreen::CodeIntro => render_code_intro(frame, app), AppScreen::CodeDownloadProgress => render_code_download_progress(frame, app), AppScreen::Keyboard => render_keyboard_explorer(frame, app), AppScreen::UiLanguageSelect => render_ui_language_select(frame, app), } } fn render_menu(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; let mh_start = ui::hint::hint(ui::hint::K_1_3, t!("menu.hint_start").as_ref()); let mh_tree = ui::hint::hint(ui::hint::K_T, t!("menu.hint_skill_tree").as_ref()); let mh_kbd = ui::hint::hint(ui::hint::K_B, t!("menu.hint_keyboard").as_ref()); let mh_stats = ui::hint::hint(ui::hint::K_S, t!("menu.hint_stats").as_ref()); let mh_settings = ui::hint::hint(ui::hint::K_C, t!("menu.hint_settings").as_ref()); let mh_quit = ui::hint::hint(ui::hint::K_Q, t!("menu.hint_quit").as_ref()); let menu_hints: Vec<&str> = vec![ mh_start.as_str(), mh_tree.as_str(), mh_kbd.as_str(), mh_stats.as_str(), mh_settings.as_str(), mh_quit.as_str(), ]; let footer_lines_vec = pack_hint_lines(&menu_hints, area.width as usize); let footer_line_count = footer_lines_vec.len().max(1) as u16; let layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Min(0), Constraint::Length(footer_line_count), ]) .split(area); let streak_text = if app.profile.streak_days > 0 { t!("menu.day_streak", days = app.profile.streak_days).to_string() } else { String::new() }; let total_keys = app.skill_tree.total_unique_keys; let unlocked = app.skill_tree.total_unlocked_count(); let mastered = app.skill_tree.total_confident_keys(&app.ranked_key_stats); let header_info = t!( "menu.key_progress", unlocked = unlocked, total = total_keys, mastered = mastered, target = app.config.target_wpm, streak = streak_text, ); let header = Paragraph::new(Line::from(vec![ Span::styled( " keydr ", Style::default() .fg(colors.header_fg()) .bg(colors.header_bg()) .add_modifier(Modifier::BOLD), ), Span::styled( header_info.as_ref(), Style::default() .fg(colors.text_pending()) .bg(colors.header_bg()), ), ])) .style(Style::default().bg(colors.header_bg())); frame.render_widget(header, layout[0]); let menu_area = ui::layout::centered_rect(50, 80, layout[1]); frame.render_widget(&app.menu, menu_area); let footer_lines: Vec = footer_lines_vec .into_iter() .map(|line| { Line::from(Span::styled( line, Style::default().fg(colors.text_pending()), )) }) .collect(); let footer = Paragraph::new(footer_lines); frame.render_widget(footer, layout[2]); } fn render_drill(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; if let Some(ref drill) = app.drill { let app_layout = AppLayout::new(area); let tier = app_layout.tier; let mode_name_t = match app.drill_mode { DrillMode::Adaptive => t!("drill.mode_adaptive"), DrillMode::Code => t!("drill.mode_code"), DrillMode::Passage => t!("drill.mode_passage"), }; let mode_name = mode_name_t.as_ref(); // Compute focus text from stored selection (what generated this drill's text) let focus_text = if let Some(ref focus) = app.current_focus { match (&focus.char_focus, &focus.bigram_focus) { (Some(ch), Some((key, _, _))) => { let bigram = format!("{}{}", key.0[0], key.0[1]); format!(" | {}", t!("drill.focus_both", ch = ch, bigram = bigram)) } (Some(ch), None) => format!(" | {}", t!("drill.focus_char", ch = ch)), (None, Some((key, _, _))) => { let bigram = format!("{}{}", key.0[0], key.0[1]); format!(" | {}", t!("drill.focus_bigram", bigram = bigram)) } (None, None) => String::new(), } } else { String::new() }; // For medium/narrow: show compact stats in header if !tier.show_sidebar() { let wpm = drill.wpm(); let accuracy = drill.accuracy(); let errors = drill.typo_count(); let wpm_label = t!("drill.header_wpm"); let acc_label = t!("drill.header_acc"); let err_label = t!("drill.header_err"); let header_text = format!( " {mode_name} | {wpm_label}: {wpm:.0} | {acc_label}: {accuracy:.1}% | {err_label}: {errors}{focus_text}" ); let header = Paragraph::new(Line::from(Span::styled( &*header_text, Style::default() .fg(colors.header_fg()) .bg(colors.header_bg()) .add_modifier(Modifier::BOLD), ))) .style(Style::default().bg(colors.header_bg())); frame.render_widget(header, app_layout.header); } else { let header_title = format!(" {mode_name}{}", t!("drill.title")); let header = Paragraph::new(Line::from(vec![ Span::styled( &*header_title, Style::default() .fg(colors.header_fg()) .bg(colors.header_bg()) .add_modifier(Modifier::BOLD), ), Span::styled( &*focus_text, Style::default() .fg(colors.focused_key()) .bg(colors.header_bg()), ), ])) .style(Style::default().bg(colors.header_bg())); frame.render_widget(header, app_layout.header); } // Build main area constraints based on tier let show_kbd = tier.show_keyboard(area.height); let show_progress = tier.show_progress_bar(area.height); // Compute active branch count for progress area height let active_branches: Vec = engine::skill_tree::BranchId::all() .iter() .copied() .filter(|&id| { matches!( app.skill_tree.branch_status(id), engine::skill_tree::BranchStatus::InProgress | engine::skill_tree::BranchStatus::Complete ) }) .collect(); let kbd_height = if show_kbd { if tier.compact_keyboard() { 6 // 3 rows + 2 border + 1 modifier space } else { 8 // 5 rows (4 + space bar) + 2 border + 1 spacing } } else { 0 }; let progress_height = if show_progress { // Adaptive progress can use: branch rows + optional separator + overall line. // Prefer the separator when space allows, but degrade if constrained. let branch_rows = if area.height >= 25 { ui::components::branch_progress_list::wrapped_branch_rows( app_layout.main.width, active_branches.len(), ) } else if !active_branches.is_empty() { 1 } else { 0 }; let desired = if app.drill_mode == DrillMode::Adaptive { (branch_rows + 2).max(2) } else { 1 }; // Keep at least 5 lines for typing area. let max_budget = app_layout .main .height .saturating_sub(kbd_height) .saturating_sub(5); desired.min(max_budget) } else { 0 }; let mut constraints: Vec = vec![Constraint::Min(5)]; if progress_height > 0 { constraints.push(Constraint::Length(progress_height)); } if show_kbd { constraints.push(Constraint::Length(kbd_height)); } let main_layout = Layout::default() .direction(Direction::Vertical) .constraints(constraints) .split(app_layout.main); let typing = TypingArea::new(drill, app.theme); frame.render_widget(typing, main_layout[0]); let mut idx = 1; if progress_height > 0 { if app.drill_mode == DrillMode::Adaptive { let progress_widget = ui::components::branch_progress_list::BranchProgressList { skill_tree: &app.skill_tree, key_stats: &app.ranked_key_stats, drill_scope: app.drill_scope, active_branches: &active_branches, theme: app.theme, height: progress_height, }; frame.render_widget(progress_widget, main_layout[idx]); } else { let source = app.drill_source_info.as_deref().unwrap_or("unknown source"); let label_t = if app.drill_mode == DrillMode::Code { t!("drill.code_source") } else { t!("drill.passage_source") }; let label = label_t.as_ref(); let source_info = Paragraph::new(Line::from(vec![ Span::styled( label, Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), ), Span::styled(source, Style::default().fg(colors.text_pending())), ])); frame.render_widget(source_info, main_layout[idx]); } idx += 1; } if show_kbd { let next_char = drill.target.get(drill.cursor).copied(); let unlocked_keys = app.skill_tree.unlocked_keys(app.drill_scope); let kbd = KeyboardDiagram::new( next_char, &unlocked_keys, &app.depressed_keys, app.theme, &app.keyboard_model, ) .compact(tier.compact_keyboard()) .shift_held(app.shift_held) .caps_lock(app.caps_lock); frame.render_widget(kbd, main_layout[idx]); } if let Some(sidebar_area) = app_layout.sidebar { let sidebar = StatsSidebar::new( drill, app.last_result.as_ref(), &app.drill_history, app.config.target_wpm, app.theme, ); frame.render_widget(sidebar, sidebar_area); } let drill_footer_text = t!("drill.footer"); let footer = Paragraph::new(Line::from(Span::styled( format!(" {} ", drill_footer_text), Style::default().fg(colors.text_pending()), ))); frame.render_widget(footer, app_layout.footer); // Show a brief countdown overlay while the post-drill input lock is active. if let Some(ms) = app.post_drill_input_lock_remaining_ms() { let msg = t!("drill.keys_reenabled", ms = ms).to_string(); let width = msg.len() as u16 + 4; // border + padding let height = 3; let x = area.x + area.width.saturating_sub(width) / 2; let y = area.y + area.height.saturating_sub(height) / 2; let overlay_area = Rect::new(x, y, width.min(area.width), height.min(area.height)); frame.render_widget(ratatui::widgets::Clear, overlay_area); let block = Block::bordered() .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(overlay_area); frame.render_widget(block, overlay_area); frame.render_widget( Paragraph::new(msg).style(Style::default().fg(colors.text_pending())), inner, ); } } } fn render_adaptive_intro_overlay(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; let overlay_height = 20u16.min(area.height.saturating_sub(2)); let overlay_width = 62u16.min(area.width.saturating_sub(4)); let left = area.x + (area.width.saturating_sub(overlay_width)) / 2; let top = area.y + (area.height.saturating_sub(overlay_height)) / 2; let overlay_area = Rect::new(left, top, overlay_width, overlay_height); frame.render_widget(ratatui::widgets::Clear, overlay_area); let title_t = t!("adaptive_intro.title"); let block = Block::bordered() .title(title_t.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())) .padding(Padding::horizontal(2)); let inner = block.inner(overlay_area); block.render(overlay_area, frame.buffer_mut()); let hint_h_back = ui::hint::hint(ui::hint::K_Q_ESC, t!("adaptive_intro.hint_back").as_ref()); let hint_h_adjust = ui::hint::hint(ui::hint::K_ARROW_LR, t!("adaptive_intro.hint_adjust").as_ref()); let hint_h_start = ui::hint::hint(ui::hint::K_ENTER_SPACE, t!("adaptive_intro.hint_start").as_ref()); let hints: Vec<&str> = vec![ hint_h_adjust.as_str(), hint_h_start.as_str(), hint_h_back.as_str(), ]; let hint_lines_text = pack_hint_lines(&hints, inner.width as usize); let footer_height = hint_lines_text.len().max(1) as u16; let (content_area, footer_area) = if inner.height > footer_height { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(footer_height)]) .split(inner); (chunks[0], Some(chunks[1])) } else { (inner, None) }; let mut lines: Vec = Vec::new(); lines.push(Line::from("")); lines.push(Line::from(Span::styled( t!("adaptive_intro.how_it_works").to_string(), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( t!("adaptive_intro.description").to_string(), Style::default().fg(colors.fg()), ))); lines.push(Line::from("")); // Target WPM adjuster let wpm_label = t!("adaptive_intro.target_wpm_label"); let wpm_value = format!("\u{2190} {} \u{2192}", app.config.target_wpm); lines.push(Line::from(vec![ Span::styled( format!("{wpm_label} "), Style::default().fg(colors.fg()).add_modifier(Modifier::BOLD), ), Span::styled( wpm_value, Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), ), ])); lines.push(Line::from("")); lines.push(Line::from(Span::styled( t!("adaptive_intro.target_wpm_desc").to_string(), Style::default().fg(colors.fg()), ))); frame.render_widget( Paragraph::new(lines).wrap(Wrap { trim: false }), content_area, ); if let Some(footer) = footer_area { let footer_lines: Vec = hint_lines_text .into_iter() .map(|line| { Line::from(Span::styled( line, Style::default().fg(colors.text_pending()), )) }) .collect(); frame.render_widget( Paragraph::new(footer_lines).wrap(Wrap { trim: false }), footer, ); } } fn render_milestone_overlay( frame: &mut ratatui::Frame, app: &App, milestone: &app::KeyMilestonePopup, ) { let area = frame.area(); let colors = &app.theme.colors; let is_key_milestone = matches!( milestone.kind, MilestoneKind::Unlock | MilestoneKind::Mastery ); // Determine overlay size based on terminal height: // Key milestones get keyboard diagrams; other milestones are text-only let kbd_mode = if is_key_milestone { overlay_keyboard_mode(area.height) } else { 0 }; let overlay_height = match &milestone.kind { MilestoneKind::BranchesAvailable => 18u16.min(area.height.saturating_sub(2)), MilestoneKind::BranchComplete | MilestoneKind::AllKeysUnlocked | MilestoneKind::AllKeysMastered => 12u16.min(area.height.saturating_sub(2)), _ => match kbd_mode { 2 => 18u16.min(area.height.saturating_sub(2)), 1 => 14u16.min(area.height.saturating_sub(2)), _ => 10u16.min(area.height.saturating_sub(2)), }, }; let overlay_width = 60u16.min(area.width.saturating_sub(4)); let left = area.x + (area.width.saturating_sub(overlay_width)) / 2; let top = area.y + (area.height.saturating_sub(overlay_height)) / 2; let overlay_area = Rect::new(left, top, overlay_width, overlay_height); // Clear the area behind the overlay frame.render_widget(ratatui::widgets::Clear, overlay_area); let title_t = match milestone.kind { MilestoneKind::Unlock => t!("milestones.unlock_title"), MilestoneKind::Mastery => t!("milestones.mastery_title"), MilestoneKind::BranchesAvailable => t!("milestones.branches_title"), MilestoneKind::BranchComplete => t!("milestones.branch_complete_title"), MilestoneKind::AllKeysUnlocked => t!("milestones.all_unlocked_title"), MilestoneKind::AllKeysMastered => t!("milestones.all_mastered_title"), }; let block = Block::bordered() .title(title_t.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(overlay_area); block.render(overlay_area, frame.buffer_mut()); let mut lines: Vec = Vec::new(); match milestone.kind { MilestoneKind::Unlock | MilestoneKind::Mastery => { let key_action_t = match milestone.kind { MilestoneKind::Unlock => t!("milestones.unlocked"), _ => t!("milestones.mastered"), }; let key_action = key_action_t.as_ref(); let key_names: Vec = milestone .keys .iter() .map(|&ch| { let name = keyboard::display::key_display_name(ch); if name.is_empty() { format!("'{ch}'") } else { name.to_string() } }) .collect(); let keys_str = key_names.join(", "); lines.push(Line::from("")); lines.push(Line::from(Span::styled( format!(" You {key_action}: {keys_str}"), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), ))); // Finger info (for unlocks) if matches!(milestone.kind, MilestoneKind::Unlock) { for (ch, finger_desc) in &milestone.finger_info { let key_label = { let name = keyboard::display::key_display_name(*ch); if name.is_empty() { format!("'{ch}'") } else { name.to_string() } }; let use_finger_msg = t!("milestones.use_finger", finger = finger_desc.to_lowercase().as_str()); lines.push(Line::from(Span::styled( format!(" {key_label}: {use_finger_msg}"), Style::default().fg(colors.fg()), ))); // Shift key guidance for shifted characters let fa = app.keyboard_model.finger_for_char(*ch); if ch.is_uppercase() || (!ch.is_lowercase() // Digits are intentionally ASCII-scoped in current drills/keyboard progression. && !ch.is_ascii_digit() && !ch.is_whitespace() && *ch != ' ') { let shift_hint = if fa.hand == keyboard::finger::Hand::Left { t!("milestones.hold_right_shift") } else { t!("milestones.hold_left_shift") }; lines.push(Line::from(Span::styled( format!(" {shift_hint}"), Style::default().fg(colors.text_pending()), ))); } } } // Encouraging message lines.push(Line::from("")); lines.push(Line::from(Span::styled( format!(" {}", milestone.message), Style::default().fg(colors.focused_key()), ))); } MilestoneKind::BranchesAvailable => { lines.push(Line::from("")); let primary_count = app.skill_tree.primary_letters().len(); let congrats_msg = t!("milestones.congratulations_all_letters", count = primary_count); lines.push(Line::from(Span::styled( format!(" {congrats_msg}"), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), ))); lines.push(Line::from("")); let new_branches = t!("milestones.new_branches_available"); lines.push(Line::from(Span::styled( format!(" {new_branches}"), Style::default().fg(colors.fg()), ))); for &branch_id in &milestone.branch_ids { let name = get_branch_definition(branch_id).display_name(); lines.push(Line::from(Span::styled( format!(" \u{2022} {name}"), Style::default().fg(colors.focused_key()), ))); } lines.push(Line::from("")); let visit_msg = t!("milestones.visit_skill_tree"); lines.push(Line::from(Span::styled( format!(" {visit_msg}"), Style::default().fg(colors.fg()), ))); let and_start = t!("milestones.and_start_training"); lines.push(Line::from(Span::styled( format!(" {and_start}"), Style::default().fg(colors.fg()), ))); lines.push(Line::from("")); let open_tree = t!("milestones.open_skill_tree"); lines.push(Line::from(Span::styled( format!(" {open_tree}"), Style::default().fg(colors.text_pending()), ))); } MilestoneKind::BranchComplete => { lines.push(Line::from("")); let branch_names: Vec = milestone .branch_ids .iter() .map(|&id| get_branch_definition(id).display_name()) .collect(); let branch_text = if branch_names.len() == 1 { branch_names[0].to_string() } else { let all_but_last = &branch_names[..branch_names.len() - 1]; let last = &branch_names[branch_names.len() - 1]; format!("{} and {}", all_but_last.join(", "), last) }; let complete_msg = t!("milestones.branch_complete_msg", branch = branch_text); lines.push(Line::from(Span::styled( format!(" {complete_msg}"), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), ))); lines.push(Line::from("")); let visit_msg = t!("milestones.visit_skill_tree"); lines.push(Line::from(Span::styled( format!(" {visit_msg}"), Style::default().fg(colors.fg()), ))); let and_start = t!("milestones.and_start_training"); lines.push(Line::from(Span::styled( format!(" {and_start}"), Style::default().fg(colors.fg()), ))); lines.push(Line::from("")); let open_tree = t!("milestones.open_skill_tree"); lines.push(Line::from(Span::styled( format!(" {open_tree}"), Style::default().fg(colors.text_pending()), ))); } MilestoneKind::AllKeysUnlocked => { lines.push(Line::from("")); let unlocked_msg = t!("milestones.all_unlocked_msg"); lines.push(Line::from(Span::styled( format!(" {unlocked_msg}"), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), ))); lines.push(Line::from("")); let desc = t!("milestones.all_unlocked_desc"); lines.push(Line::from(Span::styled( format!(" {desc}"), Style::default().fg(colors.fg()), ))); let keep_practicing = t!("milestones.keep_practicing_mastery"); lines.push(Line::from(Span::styled( format!(" {keep_practicing}"), Style::default().fg(colors.fg()), ))); let confidence = t!("milestones.confidence_complete"); lines.push(Line::from(Span::styled( format!(" {confidence}"), Style::default().fg(colors.fg()), ))); } MilestoneKind::AllKeysMastered => { lines.push(Line::from("")); let mastered_msg = t!("milestones.all_mastered_msg"); lines.push(Line::from(Span::styled( format!(" {mastered_msg}"), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), ))); let mastered_desc = t!("milestones.all_mastered_desc"); lines.push(Line::from(Span::styled( format!(" {mastered_desc}"), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), ))); lines.push(Line::from("")); let takes_practice = t!("milestones.mastery_takes_practice"); lines.push(Line::from(Span::styled( format!(" {takes_practice}"), Style::default().fg(colors.fg()), ))); let keep_drilling = t!("milestones.keep_drilling"); lines.push(Line::from(Span::styled( format!(" {keep_drilling}"), Style::default().fg(colors.fg()), ))); } } // Keyboard diagram (only for key milestones, if space permits) if kbd_mode > 0 && is_key_milestone { let min_kbd_height: u16 = if kbd_mode == 2 { 6 } else { 4 }; let remaining = inner.height.saturating_sub(lines.len() as u16 + 2); if remaining >= min_kbd_height { let kbd_y_start = inner.y + lines.len() as u16 + 1; let kbd_height = remaining.min(if kbd_mode == 2 { 8 } else { 6 }); let kbd_area = Rect::new(inner.x, kbd_y_start, inner.width, kbd_height); let milestone_key = milestone.keys.first().copied(); let unlocked_keys = app.skill_tree.unlocked_keys(app.drill_scope); let is_shifted = milestone_key.is_some_and(|ch| { ch.is_uppercase() || app.keyboard_model.shifted_to_base(ch).is_some() }); let kbd = KeyboardDiagram::new( None, &unlocked_keys, &app.depressed_keys, app.theme, &app.keyboard_model, ) .selected_key(milestone_key) .compact(kbd_mode == 1) .shift_held(is_shifted) .caps_lock(app.caps_lock); frame.render_widget(kbd, kbd_area); } } // Render the text content let text_area = Rect::new( inner.x, inner.y, inner.width, inner.height.saturating_sub(1), ); Paragraph::new(lines).render(text_area, frame.buffer_mut()); // Footer let footer_y = inner.y + inner.height.saturating_sub(1); if footer_y < inner.y + inner.height { let footer_area = Rect::new(inner.x, footer_y, inner.width, 1); let footer_text = if let Some(ms) = app.post_drill_input_lock_remaining_ms() { format!(" {}", t!("milestones.input_blocked", ms = ms)) } else if milestone_supports_skill_tree_shortcut(milestone) { format!(" {}", ui::hint::hint(ui::hint::K_T, t!("milestones.hint_skill_tree_continue").as_ref())) } else { format!(" {}", t!("milestones.hint_any_key")) }; let footer = Paragraph::new(Line::from(Span::styled( footer_text, Style::default().fg(colors.text_pending()), ))); frame.render_widget(footer, footer_area); } } fn overlay_keyboard_mode(height: u16) -> u8 { if height >= 25 { 2 // full } else if height >= 15 { 1 // compact } else { 0 // text only } } #[cfg(test)] mod review_tests { use super::*; use crate::engine::skill_tree::SkillTreeProgress; use crate::session::result::DrillResult; use chrono::{TimeDelta, Utc}; /// Create an App for testing with the store disabled so tests never /// read or write the user's real data files. fn test_app() -> App { App::new_test() } fn test_result(ts_offset_secs: i64) -> DrillResult { DrillResult { wpm: 60.0, cpm: 300.0, accuracy: 98.0, correct: 49, incorrect: 1, total_chars: 50, elapsed_secs: 10.0, timestamp: Utc::now() + TimeDelta::seconds(ts_offset_secs), per_key_times: vec![], drill_mode: "adaptive".to_string(), ranked: true, partial: false, completion_percent: 100.0, } } #[test] fn locked_branch_notice_uses_primary_letter_count() { let mut app = test_app(); app.skill_tree = crate::engine::skill_tree::SkillTree::new_with_primary_sequence( SkillTreeProgress::default(), "abcde", ); assert_eq!( locked_branch_notice(&app), "Complete 5 primary letters to unlock branches" ); } #[test] fn milestone_overlay_blocks_underlying_input() { let mut app = test_app(); app.screen = AppScreen::Drill; app.drill = Some(crate::session::drill::DrillState::new("abc")); app.milestone_queue .push_back(crate::app::KeyMilestonePopup { kind: crate::app::MilestoneKind::Unlock, keys: vec!['a'], finger_info: vec![('a', "left pinky".to_string())], message: "msg".to_string(), branch_ids: vec![], }); let before_cursor = app.drill.as_ref().map(|d| d.cursor).unwrap_or(0); handle_key( &mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), ); let after_cursor = app.drill.as_ref().map(|d| d.cursor).unwrap_or(0); assert_eq!(before_cursor, after_cursor); assert!(app.milestone_queue.is_empty()); } #[test] fn milestone_queue_chains_before_result_actions() { let mut app = test_app(); app.screen = AppScreen::DrillResult; app.milestone_queue .push_back(crate::app::KeyMilestonePopup { kind: crate::app::MilestoneKind::Unlock, keys: vec!['a'], finger_info: vec![('a', "left pinky".to_string())], message: "msg1".to_string(), branch_ids: vec![], }); app.milestone_queue .push_back(crate::app::KeyMilestonePopup { kind: crate::app::MilestoneKind::Mastery, keys: vec!['a'], finger_info: vec![('a', "left pinky".to_string())], message: "msg2".to_string(), branch_ids: vec![], }); handle_key( &mut app, KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), ); assert_eq!(app.screen, AppScreen::DrillResult); assert_eq!(app.milestone_queue.len(), 1); handle_key( &mut app, KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), ); assert_eq!(app.screen, AppScreen::DrillResult); assert!(app.milestone_queue.is_empty()); // Now normal result action should apply. handle_key( &mut app, KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), ); assert_eq!(app.screen, AppScreen::Menu); } #[test] fn post_drill_lock_blocks_result_shortcuts_temporarily() { let mut app = test_app(); app.screen = AppScreen::DrillResult; app.last_result = Some(test_result(1)); app.post_drill_input_lock_until = Some(Instant::now() + std::time::Duration::from_millis(500)); handle_key( &mut app, KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), ); assert_eq!(app.screen, AppScreen::DrillResult); } #[test] fn post_drill_lock_blocks_milestone_dismissal_temporarily() { let mut app = test_app(); app.screen = AppScreen::DrillResult; app.milestone_queue .push_back(crate::app::KeyMilestonePopup { kind: crate::app::MilestoneKind::Unlock, keys: vec!['a'], finger_info: vec![('a', "left pinky".to_string())], message: "msg".to_string(), branch_ids: vec![], }); app.post_drill_input_lock_until = Some(Instant::now() + std::time::Duration::from_millis(500)); handle_key( &mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), ); assert_eq!(app.milestone_queue.len(), 1); } #[test] fn milestone_t_shortcut_opens_skill_tree_for_congrats_popup() { let mut app = test_app(); app.screen = AppScreen::DrillResult; app.milestone_queue .push_back(crate::app::KeyMilestonePopup { kind: crate::app::MilestoneKind::BranchesAvailable, keys: vec![], finger_info: vec![], message: "msg".to_string(), branch_ids: vec![engine::skill_tree::BranchId::Capitals], }); handle_key( &mut app, KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), ); assert!(app.milestone_queue.is_empty()); assert_eq!(app.screen, AppScreen::SkillTree); } #[test] fn overlay_mode_height_boundaries() { assert_eq!(overlay_keyboard_mode(14), 0); assert_eq!(overlay_keyboard_mode(15), 1); assert_eq!(overlay_keyboard_mode(24), 1); assert_eq!(overlay_keyboard_mode(25), 2); } #[test] fn result_delete_shortcut_opens_confirmation_for_latest() { let mut app = test_app(); app.screen = AppScreen::DrillResult; app.last_result = Some(test_result(2)); app.drill_history = vec![test_result(1), test_result(2)]; app.history_selected = 1; handle_key( &mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), ); assert!(app.history_confirm_delete); assert_eq!(app.history_selected, 0); } #[test] fn result_delete_confirmation_yes_deletes_latest() { let mut app = test_app(); app.screen = AppScreen::DrillResult; app.last_result = Some(test_result(3)); let older = test_result(1); let newer = test_result(2); app.drill_history = vec![older.clone(), newer.clone()]; handle_key( &mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), ); handle_key( &mut app, KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE), ); assert!(!app.history_confirm_delete); assert_eq!(app.drill_history.len(), 1); assert_eq!(app.drill_history[0].timestamp, older.timestamp); assert_eq!(app.screen, AppScreen::Drill); assert!(app.drill.is_some()); } #[test] fn result_delete_confirmation_cancel_keeps_history() { let mut app = test_app(); app.screen = AppScreen::DrillResult; app.last_result = Some(test_result(2)); app.drill_history = vec![test_result(1), test_result(2)]; handle_key( &mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), ); handle_key( &mut app, KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), ); assert!(!app.history_confirm_delete); assert_eq!(app.drill_history.len(), 2); assert_eq!(app.screen, AppScreen::DrillResult); } #[test] fn result_continue_shortcuts_start_next_drill() { let mut app = test_app(); app.screen = AppScreen::DrillResult; app.last_result = Some(test_result(2)); handle_key( &mut app, KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), ); assert_eq!(app.screen, AppScreen::Drill); app.screen = AppScreen::DrillResult; handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(app.screen, AppScreen::Drill); app.screen = AppScreen::DrillResult; handle_key( &mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE), ); assert_eq!(app.screen, AppScreen::Drill); } #[test] fn result_continue_code_uses_last_language_params() { let mut app = test_app(); app.screen = AppScreen::DrillResult; app.last_result = Some(test_result(2)); app.drill_mode = DrillMode::Code; app.config.code_downloads_enabled = false; app.config.code_language = "python".to_string(); app.last_code_drill_language = Some("rust".to_string()); handle_key( &mut app, KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), ); assert_eq!(app.screen, AppScreen::Drill); assert_eq!(app.drill_mode, DrillMode::Code); assert_eq!(app.last_code_drill_language.as_deref(), Some("rust")); } /// Helper: count how many settings modal/edit flags are active fn modal_edit_count(app: &App) -> usize { let mut count = 0; if app.settings_confirm_import { count += 1; } if app.settings_export_conflict { count += 1; } if app.is_editing_path() { count += 1; } count } #[test] fn settings_modal_invariant_enter_export_path_clears_others() { let mut app = test_app(); app.screen = AppScreen::Settings; // First, activate import confirmation app.settings_selected = SettingItem::ImportData.index(); handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(app.settings_confirm_import); assert!(modal_edit_count(&app) <= 1); // Cancel it handle_settings_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(!app.settings_confirm_import); // Enter export path editing app.settings_selected = SettingItem::ExportPath.index(); handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(app.is_editing_field(SettingItem::ExportPath.index())); assert!(modal_edit_count(&app) <= 1); // Esc out handle_settings_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(!app.is_editing_path()); } #[test] fn settings_modal_invariant_enter_import_path_clears_others() { let mut app = test_app(); app.screen = AppScreen::Settings; // Activate export path editing first app.settings_selected = SettingItem::ExportPath.index(); handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(app.is_editing_field(SettingItem::ExportPath.index())); // Esc out, then enter import path editing handle_settings_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); app.settings_selected = SettingItem::ImportPath.index(); handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(app.is_editing_field(SettingItem::ImportPath.index())); assert!(!app.is_editing_field(SettingItem::ExportPath.index())); assert!(modal_edit_count(&app) <= 1); } #[test] fn settings_confirm_import_dialog_y_n_esc() { let mut app = test_app(); app.screen = AppScreen::Settings; // Trigger import confirmation app.settings_selected = SettingItem::ImportData.index(); handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(app.settings_confirm_import); // 'n' cancels handle_settings_key( &mut app, KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), ); assert!(!app.settings_confirm_import); // Trigger again app.settings_selected = SettingItem::ImportData.index(); handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(app.settings_confirm_import); // Esc cancels handle_settings_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(!app.settings_confirm_import); } #[test] fn settings_status_message_dismissed_on_keypress() { let mut app = test_app(); app.screen = AppScreen::Settings; // Set a status message app.settings_status_message = Some(crate::app::StatusMessage { kind: StatusKind::Success, text: "test".to_string(), }); // Any keypress should dismiss it handle_settings_key( &mut app, KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), ); assert!(app.settings_status_message.is_none()); } #[test] fn smart_rename_canonical_filename() { use crate::app::next_available_path; let dir = tempfile::TempDir::new().unwrap(); let base = dir.path(); // Create base file let base_path = base.join("keydr-export-2026-01-01.json"); std::fs::write(&base_path, "{}").unwrap(); // First rename: picks -1 let result = next_available_path(base_path.to_str().unwrap()); assert!(result.ends_with("keydr-export-2026-01-01-1.json")); // Create -1 std::fs::write(base.join("keydr-export-2026-01-01-1.json"), "{}").unwrap(); // From base: picks -2 let result = next_available_path(base_path.to_str().unwrap()); assert!(result.ends_with("keydr-export-2026-01-01-2.json")); // From -1 path: normalizes to base stem and picks -2 let path_1 = base.join("keydr-export-2026-01-01-1.json"); let result = next_available_path(path_1.to_str().unwrap()); assert!(result.ends_with("keydr-export-2026-01-01-2.json")); } #[test] fn smart_rename_custom_filename() { use crate::app::next_available_path; let dir = tempfile::TempDir::new().unwrap(); let base = dir.path(); let custom_path = base.join("my-backup.json"); std::fs::write(&custom_path, "{}").unwrap(); let result = next_available_path(custom_path.to_str().unwrap()); assert!(result.ends_with("my-backup-1.json")); std::fs::write(base.join("my-backup-1.json"), "{}").unwrap(); let result = next_available_path(custom_path.to_str().unwrap()); assert!(result.ends_with("my-backup-2.json")); } // --- Keyboard state tracking tests --- /// Helper to build a KeyEvent with specific state flags. fn key_event_with_state( code: KeyCode, modifiers: KeyModifiers, kind: KeyEventKind, state: KeyEventState, ) -> KeyEvent { KeyEvent { code, modifiers, kind, state, } } #[test] fn caps_lock_set_from_state_flag() { let mut app = test_app(); assert!(!app.caps_lock); // Modifier event with CAPS_LOCK in state turns it on handle_key( &mut app, key_event_with_state( KeyCode::Modifier(ModifierKeyCode::LeftShift), KeyModifiers::SHIFT, KeyEventKind::Press, KeyEventState::CAPS_LOCK, ), ); assert!(app.caps_lock); } #[test] fn caps_lock_not_cleared_by_char_event_with_empty_state() { let mut app = test_app(); app.caps_lock = true; app.screen = AppScreen::Drill; app.drill = Some(crate::session::drill::DrillState::new("abc")); // Character event with empty state should NOT clear caps_lock handle_key( &mut app, key_event_with_state( KeyCode::Char('A'), KeyModifiers::SHIFT, KeyEventKind::Press, KeyEventState::NONE, ), ); assert!( app.caps_lock, "char event with empty state must not clear caps_lock" ); } #[test] fn caps_lock_cleared_by_modifier_event_without_caps_flag() { let mut app = test_app(); app.caps_lock = true; // Modifier event WITHOUT CAPS_LOCK in state clears it handle_key( &mut app, key_event_with_state( KeyCode::Modifier(ModifierKeyCode::LeftShift), KeyModifiers::SHIFT, KeyEventKind::Press, KeyEventState::NONE, ), ); assert!( !app.caps_lock, "modifier event without CAPS_LOCK flag should clear caps_lock" ); } #[test] fn caps_lock_on_uppercase_char_does_not_set_shift() { let mut app = test_app(); app.caps_lock = true; app.screen = AppScreen::Drill; app.drill = Some(crate::session::drill::DrillState::new("ABC")); // Caps lock on, typing 'A' — crossterm may report SHIFT modifier, // but this is caps lock, not physical shift handle_key( &mut app, key_event_with_state( KeyCode::Char('A'), KeyModifiers::SHIFT, KeyEventKind::Press, KeyEventState::NONE, ), ); assert!( !app.shift_held, "uppercase char with caps lock should not set shift_held" ); } #[test] fn caps_lock_on_lowercase_char_with_shift_sets_shift() { let mut app = test_app(); app.caps_lock = true; app.screen = AppScreen::Drill; app.drill = Some(crate::session::drill::DrillState::new("abc")); // Caps lock on + shift held produces lowercase: shift IS held handle_key( &mut app, key_event_with_state( KeyCode::Char('a'), KeyModifiers::SHIFT, KeyEventKind::Press, KeyEventState::NONE, ), ); assert!( app.shift_held, "lowercase char with caps+shift should set shift_held" ); } #[test] fn caps_lock_off_uppercase_char_with_shift_sets_shift() { let mut app = test_app(); assert!(!app.caps_lock); app.screen = AppScreen::Drill; app.drill = Some(crate::session::drill::DrillState::new("ABC")); // Normal shift+a = 'A', caps lock off handle_key( &mut app, key_event_with_state( KeyCode::Char('A'), KeyModifiers::SHIFT, KeyEventKind::Press, KeyEventState::NONE, ), ); assert!( app.shift_held, "uppercase char without caps lock should set shift_held" ); } #[test] fn caps_lock_off_lowercase_char_without_shift_clears_shift() { let mut app = test_app(); app.shift_held = true; app.screen = AppScreen::Drill; app.drill = Some(crate::session::drill::DrillState::new("abc")); // Normal lowercase typing, no shift handle_key( &mut app, key_event_with_state( KeyCode::Char('a'), KeyModifiers::NONE, KeyEventKind::Press, KeyEventState::NONE, ), ); assert!( !app.shift_held, "lowercase char without shift should clear shift_held" ); } #[test] fn shift_modifier_press_sets_shift_held() { let mut app = test_app(); handle_key( &mut app, key_event_with_state( KeyCode::Modifier(ModifierKeyCode::LeftShift), KeyModifiers::SHIFT, KeyEventKind::Press, KeyEventState::NONE, ), ); assert!(app.shift_held); } #[test] fn shift_modifier_release_clears_shift_held() { let mut app = test_app(); app.shift_held = true; handle_key( &mut app, key_event_with_state( KeyCode::Modifier(ModifierKeyCode::RightShift), KeyModifiers::SHIFT, KeyEventKind::Release, KeyEventState::NONE, ), ); assert!(!app.shift_held); } #[test] fn depressed_keys_tracks_char_press() { let mut app = test_app(); app.screen = AppScreen::Drill; app.drill = Some(crate::session::drill::DrillState::new("abc")); handle_key( &mut app, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), ); assert!(app.depressed_keys.contains(&'a')); } #[test] fn depressed_keys_release_removes_char() { let mut app = test_app(); app.depressed_keys.insert('a'); handle_key( &mut app, key_event_with_state( KeyCode::Char('a'), KeyModifiers::NONE, KeyEventKind::Release, KeyEventState::NONE, ), ); assert!(!app.depressed_keys.contains(&'a')); } #[test] fn depressed_keys_normalizes_shifted_chars_to_keyboard_base_key() { let mut app = test_app(); app.set_keyboard_layout("fr_azerty") .expect("fr_azerty should be available in tests"); app.screen = AppScreen::Drill; app.drill = Some(crate::session::drill::DrillState::new("2")); handle_key( &mut app, key_event_with_state( KeyCode::Char('2'), KeyModifiers::SHIFT, KeyEventKind::Press, KeyEventState::NONE, ), ); assert!( app.depressed_keys.contains(&'é'), "AZERTY shifted digit should map to its base physical key" ); handle_key( &mut app, key_event_with_state( KeyCode::Char('2'), KeyModifiers::SHIFT, KeyEventKind::Release, KeyEventState::NONE, ), ); assert!( !app.depressed_keys.contains(&'é'), "Release should clear normalized depressed key entry" ); } #[test] fn caps_lock_cleared_by_capslock_key_without_caps_flag() { let mut app = test_app(); app.caps_lock = true; // Pressing CapsLock key to toggle off: event has KeyCode::CapsLock // but state no longer contains CAPS_LOCK handle_key( &mut app, key_event_with_state( KeyCode::CapsLock, KeyModifiers::NONE, KeyEventKind::Press, KeyEventState::NONE, ), ); assert!( !app.caps_lock, "CapsLock key event without CAPS_LOCK state should clear caps_lock" ); } #[test] fn caps_lock_non_alpha_char_with_shift_still_sets_shift() { let mut app = test_app(); app.caps_lock = true; app.screen = AppScreen::Drill; app.drill = Some(crate::session::drill::DrillState::new("!@#")); // Caps lock doesn't affect non-alpha chars like '!', so SHIFT // modifier should be trusted as-is handle_key( &mut app, key_event_with_state( KeyCode::Char('!'), KeyModifiers::SHIFT, KeyEventKind::Press, KeyEventState::NONE, ), ); assert!( app.shift_held, "non-alpha char with shift should set shift_held regardless of caps" ); } #[test] fn build_ngram_tab_data_maps_fields_correctly() { use crate::engine::ngram_stats::{ANOMALY_STREAK_REQUIRED, BigramKey}; let mut app = test_app(); // Set up char stats with known EMA error rates for &ch in &['e', 't', 'a', 'o', 'n', 'i'] { let stat = app.ranked_key_stats.stats.entry(ch).or_default(); stat.confidence = 0.95; stat.filtered_time_ms = 360.0; stat.sample_count = 50; stat.total_count = 50; stat.error_rate_ema = 0.03; } // Make 'n' weak so we get a focused char app.ranked_key_stats.stats.get_mut(&'n').unwrap().confidence = 0.5; app.ranked_key_stats .stats .get_mut(&'n') .unwrap() .filtered_time_ms = 686.0; // Add a confirmed error anomaly bigram let et_key = BigramKey(['e', 't']); let stat = app .ranked_bigram_stats .stats .entry(et_key.clone()) .or_default(); stat.sample_count = 30; stat.error_rate_ema = 0.80; stat.error_anomaly_streak = ANOMALY_STREAK_REQUIRED; // Add an unconfirmed anomaly bigram (low samples) let ao_key = BigramKey(['a', 'o']); let stat = app .ranked_bigram_stats .stats .entry(ao_key.clone()) .or_default(); stat.sample_count = 10; stat.error_rate_ema = 0.60; stat.error_anomaly_streak = 1; // Set drill scope app.drill_scope = DrillScope::Global; app.stats_tab = 5; let data = build_ngram_tab_data(&app); // Verify scope label assert_eq!(data.scope_label, "Global"); // Verify bigram count assert_eq!(data.total_bigrams, app.ranked_bigram_stats.stats.len()); // Verify hesitation threshold assert!(data.hesitation_threshold_ms >= 800.0); // Verify FocusSelection has both char and bigram assert!(data.focus.char_focus.is_some(), "should have char focus"); assert!( data.focus.bigram_focus.is_some(), "should have bigram focus" ); // Verify error anomaly rows have correct fields populated if !data.error_anomalies.is_empty() { let row = &data.error_anomalies[0]; assert!(row.anomaly_pct > 0.0, "anomaly_pct should be positive"); assert!(row.sample_count > 0, "sample_count should be positive"); } // Verify 'ao' appears in error anomalies (high error rate, above min samples) let ao_row = data.error_anomalies.iter().find(|r| r.bigram == "ao"); if let Some(ao) = ao_row { assert_eq!(ao.sample_count, 10); assert!(!ao.confirmed, "ao should not be confirmed (low samples)"); } // Add a speed anomaly bigram and verify speed_anomalies mapping let ni_key = BigramKey(['n', 'i']); let stat = app .ranked_bigram_stats .stats .entry(ni_key.clone()) .or_default(); stat.sample_count = 25; stat.error_rate_ema = 0.02; stat.filtered_time_ms = 600.0; // much slower than char 'i' baseline stat.speed_anomaly_streak = ANOMALY_STREAK_REQUIRED; // Make char 'i' baseline fast enough that 600ms is a big anomaly app.ranked_key_stats .stats .get_mut(&'i') .unwrap() .filtered_time_ms = 200.0; let data2 = build_ngram_tab_data(&app); // Verify speed anomalies contain our bigram with correct field mapping let ni_row = data2.speed_anomalies.iter().find(|r| r.bigram == "ni"); assert!(ni_row.is_some(), "ni should appear in speed_anomalies"); let ni = ni_row.unwrap(); assert_eq!(ni.sample_count, 25); assert!(ni.anomaly_pct > 100.0, "600ms / 200ms => 200% anomaly"); assert!( (ni.expected_baseline - 200.0).abs() < 1.0, "expected baseline should be char 'i' speed (200ms), got {}", ni.expected_baseline ); assert!( ni.confirmed, "ni should be confirmed (samples >= 20, streak >= required)" ); } #[test] fn drill_screen_input_lock_blocks_normal_keys() { let mut app = test_app(); app.screen = AppScreen::Drill; app.drill = Some(crate::session::drill::DrillState::new("abc")); app.post_drill_input_lock_until = Some(Instant::now() + std::time::Duration::from_millis(500)); let before_cursor = app.drill.as_ref().unwrap().cursor; handle_key( &mut app, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), ); 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!(app.screen, AppScreen::Drill); } #[test] fn ctrl_c_passes_through_input_lock() { let mut app = test_app(); app.screen = AppScreen::Drill; app.drill = Some(crate::session::drill::DrillState::new("abc")); app.post_drill_input_lock_until = Some(Instant::now() + std::time::Duration::from_millis(500)); handle_key( &mut app, KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), ); 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_skill_tree(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 } fn render_skill_tree_to_string(app: &App) -> String { render_skill_tree_to_string_with_size(app, 120, 40) } fn render_app_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(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 } #[test] fn footer_shows_completion_error_and_clears_on_keystroke() { let mut app = test_app(); app.screen = AppScreen::Settings; app.settings_selected = SettingItem::ExportPath.index(); // Enter editing mode handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(app.is_editing_field(SettingItem::ExportPath.index())); // Set path to nonexistent dir and trigger tab completion error if let Some((_, ref mut input)) = app.settings_editing_path { input.handle(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); // clear for ch in "/nonexistent_zzz_dir/".chars() { input.handle(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } input.handle(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert!(input.completion_error); } // Render and check footer contains the error hint let output = render_settings_to_string(&app); assert!( output.contains("(cannot read directory)"), "Footer should show completion error hint" ); // Press a non-tab key to clear the error 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); assert!( !output_after.contains("(cannot read directory)"), "Footer error hint should clear after non-Tab keystroke" ); } #[test] fn footer_shows_editing_hints_when_path_editing() { let mut app = test_app(); app.screen = AppScreen::Settings; app.settings_selected = SettingItem::ExportPath.index(); // Before editing: shows default hints let output_before = render_settings_to_string(&app); assert!(output_before.contains("[ESC] Save & back")); // Enter editing mode handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); // After editing: shows editing hints let output_during = render_settings_to_string(&app); assert!(output_during.contains("[Enter] Confirm")); assert!(output_during.contains("[ESC] Cancel")); assert!(output_during.contains("[Tab] Complete")); } #[test] fn settings_show_dictionary_language_display_name() { let mut app = test_app(); app.screen = AppScreen::Settings; app.config.dictionary_language = "en".to_string(); let output = render_settings_to_string(&app); assert!(output.contains("Dictionary Language")); assert!(output.contains("English")); } #[test] fn settings_show_keyboard_layout_value() { let mut app = test_app(); app.screen = AppScreen::Settings; app.config.keyboard_layout = "qwerty".to_string(); let output = render_settings_to_string(&app); assert!(output.contains("Keyboard Layout")); assert!(output.contains("qwerty")); } #[test] fn settings_show_language_without_preview_label() { let mut app = test_app(); app.screen = AppScreen::Settings; app.set_dictionary_language("de") .expect("de should be selectable"); let output = render_settings_to_string(&app); assert!(output.contains("Deutsch")); assert!(!output.contains("Deutsch (preview)")); assert!(output.contains("de_qwertz")); assert!(!output.contains("qwerty (preview)")); } #[test] fn settings_enter_on_dictionary_language_opens_selector() { let mut app = test_app(); app.screen = AppScreen::Settings; app.settings_selected = SettingItem::DictionaryLanguage.index(); handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(app.screen, AppScreen::DictionaryLanguageSelect); assert_eq!(app.dictionary_language_selected, 0); } #[test] fn settings_enter_on_keyboard_layout_opens_selector() { let mut app = test_app(); app.screen = AppScreen::Settings; app.settings_selected = SettingItem::KeyboardLayout.index(); handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(app.screen, AppScreen::KeyboardLayoutSelect); assert_eq!(app.keyboard_layout_selected, 0); } #[test] fn dictionary_language_selector_confirm_applies_selection_and_returns_to_settings() { let mut app = test_app(); app.go_to_dictionary_language_select(); app.dictionary_language_selected = crate::l10n::language_pack::language_packs() .iter() .position(|pack| pack.language_key == "de") .expect("de language pack should exist"); handle_dictionary_language_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(app.screen, AppScreen::Settings); assert_eq!(app.config.dictionary_language, "de"); assert_eq!( app.settings_selected, SettingItem::DictionaryLanguage.index() ); let status = app .settings_status_message .as_ref() .expect("language switch should show layout reset message"); assert!(status.text.contains("Keyboard layout reset")); assert!(status.text.contains("de_qwertz")); } #[test] fn keyboard_layout_selector_confirm_keeps_selected_language() { let mut app = test_app(); app.set_dictionary_language("es") .expect("es should be selectable"); app.go_to_keyboard_layout_select(); app.keyboard_layout_selected = crate::keyboard::model::KeyboardModel::supported_layout_keys() .iter() .position(|&key| key == "de_qwertz") .expect("de_qwertz layout should exist"); handle_keyboard_layout_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(app.screen, AppScreen::Settings); assert_eq!(app.config.keyboard_layout, "de_qwertz"); assert_eq!(app.config.dictionary_language, "es"); assert_eq!(app.settings_selected, SettingItem::KeyboardLayout.index()); assert!(app.settings_status_message.is_none()); } #[test] fn dictionary_language_selector_zero_shortcut_is_ignored() { let mut app = test_app(); app.go_to_dictionary_language_select(); app.dictionary_language_selected = 3; handle_dictionary_language_key( &mut app, KeyEvent::new(KeyCode::Char('0'), KeyModifiers::NONE), ); assert_eq!(app.screen, AppScreen::DictionaryLanguageSelect); assert_eq!(app.dictionary_language_selected, 3); } #[test] fn keyboard_layout_selector_zero_shortcut_is_ignored() { let mut app = test_app(); app.go_to_keyboard_layout_select(); app.keyboard_layout_selected = 2; handle_keyboard_layout_key( &mut app, KeyEvent::new(KeyCode::Char('0'), KeyModifiers::NONE), ); assert_eq!(app.screen, AppScreen::KeyboardLayoutSelect); assert_eq!(app.keyboard_layout_selected, 2); } #[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("[y] Unlock")); } #[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("[y] Unlock")); } #[test] fn milestone_popup_footer_shows_skill_tree_hint() { let mut app = test_app(); app.screen = AppScreen::DrillResult; app.milestone_queue .push_back(crate::app::KeyMilestonePopup { kind: crate::app::MilestoneKind::BranchesAvailable, keys: vec![], finger_info: vec![], message: "msg".to_string(), branch_ids: vec![engine::skill_tree::BranchId::Capitals], }); let output = render_app_to_string_with_size(&app, 100, 28); assert!(output.contains("[t] Open Skill Tree")); } #[test] fn keyboard_explorer_non_shifted_selection_clears_latched_shift() { let mut app = test_app(); app.screen = AppScreen::Keyboard; app.shift_held = true; keyboard_explorer_select_key(&mut app, 'a'); assert_eq!(app.keyboard_explorer_selected, Some('a')); assert!(!app.shift_held); } #[test] fn keyboard_explorer_shifted_selection_keeps_latched_shift() { let mut app = test_app(); app.screen = AppScreen::Keyboard; app.shift_held = true; keyboard_explorer_select_key(&mut app, 'A'); assert_eq!(app.keyboard_explorer_selected, Some('A')); assert!(app.shift_held); } #[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!(!crate::ui::components::skill_tree::use_expanded_level_spacing(base + 1, id)); assert!(crate::ui::components::skill_tree::use_expanded_level_spacing(base + 2, id)); } } fn render_result(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); if let Some(ref result) = app.last_result { let centered = ui::layout::centered_rect(60, 70, area); let dashboard = Dashboard::new(result, app.theme, app.post_drill_input_lock_remaining_ms()); frame.render_widget(dashboard, centered); if app.history_confirm_delete && !app.drill_history.is_empty() { let colors = &app.theme.colors; let dialog_width = 34u16; let dialog_height = 5u16; 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); let idx = app.drill_history.len().saturating_sub(app.history_selected); let dialog_text = t!("stats.delete_confirm", idx = idx); frame.render_widget(ratatui::widgets::Clear, dialog_area); let confirm_title = t!("stats.confirm_title"); let dialog = Paragraph::new(vec![ Line::from(""), Line::from(Span::styled( format!(" {} ", dialog_text), Style::default().fg(colors.fg()), )), ]) .style(Style::default().bg(colors.bg())) .block( Block::bordered() .title(confirm_title.as_ref()) .border_style(Style::default().fg(colors.error())) .style(Style::default().bg(colors.bg())), ); frame.render_widget(dialog, dialog_area); } } } fn render_stats(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let ngram_data = if app.stats_tab == 5 { Some(build_ngram_tab_data(app)) } else { None }; let dashboard = StatsDashboard::new( &app.drill_history, &app.key_stats, app.stats_tab, app.config.target_wpm, app.skill_tree.total_unlocked_count(), app.skill_tree.total_confident_keys(&app.ranked_key_stats), app.skill_tree.total_unique_keys, app.theme, app.history_selected, app.history_scroll, app.history_confirm_delete, &app.keyboard_model, ngram_data.as_ref(), ); frame.render_widget(dashboard, area); } fn keep_history_selection_visible(app: &mut App, page_size: usize) { let viewport = page_size.max(1); if app.history_selected < app.history_scroll { app.history_scroll = app.history_selected; } else if app.history_selected >= app.history_scroll + viewport { app.history_scroll = app.history_selected + 1 - viewport; } } fn current_history_page_size() -> usize { match crossterm::terminal::size() { Ok((w, h)) => history_page_size_for_terminal(w, h), Err(_) => 10, } } fn build_ngram_tab_data(app: &App) -> NgramTabData { use engine::ngram_stats::{self, select_focus}; let focus = select_focus( &app.skill_tree, app.drill_scope, &app.ranked_key_stats, &app.ranked_bigram_stats, ); let unlocked = app.skill_tree.unlocked_keys(app.drill_scope); let error_anomalies_raw = app .ranked_bigram_stats .error_anomaly_bigrams(&app.ranked_key_stats, &unlocked); let speed_anomalies_raw = app .ranked_bigram_stats .speed_anomaly_bigrams(&app.ranked_key_stats, &unlocked); let error_anomalies: Vec = error_anomalies_raw .iter() .map(|a| AnomalyBigramRow { bigram: format!("{}{}", a.key.0[0], a.key.0[1]), anomaly_pct: a.anomaly_pct, sample_count: a.sample_count, error_count: a.error_count, error_rate_ema: a.error_rate_ema, speed_ms: a.speed_ms, expected_baseline: a.expected_baseline, confirmed: a.confirmed, }) .collect(); let speed_anomalies: Vec = speed_anomalies_raw .iter() .map(|a| AnomalyBigramRow { bigram: format!("{}{}", a.key.0[0], a.key.0[1]), anomaly_pct: a.anomaly_pct, sample_count: a.sample_count, error_count: a.error_count, error_rate_ema: a.error_rate_ema, speed_ms: a.speed_ms, expected_baseline: a.expected_baseline, confirmed: a.confirmed, }) .collect(); let scope_label = match app.drill_scope { DrillScope::Global => "Global".to_string(), DrillScope::Branch(id) => format!("Branch: {}", id.to_key()), }; let hesitation_threshold_ms = ngram_stats::hesitation_threshold(app.user_median_transition_ms); NgramTabData { focus, error_anomalies, speed_anomalies, total_bigrams: app.ranked_bigram_stats.stats.len(), hesitation_threshold_ms, scope_label, } } fn render_settings(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; let centered = ui::layout::centered_rect(60, 80, area); let settings_title = t!("settings.title"); let block = Block::bordered() .title(settings_title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); block.render(centered, frame.buffer_mut()); let fields = settings_fields(app); let header_height = if inner.height > 0 { 1 } else { 0 }; // Compute footer hints early so we know how many lines they need. let completion_error = app .settings_editing_path .as_ref() .map(|(_, input)| input.completion_error) .unwrap_or(false); let fh_move = ui::hint::hint(ui::hint::K_ARROW_LR, t!("settings.hint_move").as_ref()); let fh_tab = ui::hint::hint(ui::hint::K_TAB, t!("settings.hint_tab_complete").as_ref()); let fh_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("settings.hint_confirm").as_ref()); let fh_cancel = ui::hint::hint(ui::hint::K_ESC, t!("settings.hint_cancel").as_ref()); let fh_save = ui::hint::hint(ui::hint::K_ESC, t!("settings.hint_save_back").as_ref()); let fh_change = ui::hint::hint(ui::hint::K_ENTER_ARROWS, t!("settings.hint_change_value").as_ref()); let fh_edit = ui::hint::hint(ui::hint::K_ENTER_ON_PATH, t!("settings.hint_edit_path").as_ref()); let footer_hints: Vec<&str> = if app.is_editing_path() { let mut hints = vec![ fh_move.as_str(), fh_tab.as_str(), fh_confirm.as_str(), fh_cancel.as_str(), ]; if completion_error { hints.push("(cannot read directory)"); } hints } else { vec![ fh_save.as_str(), fh_change.as_str(), fh_edit.as_str(), ] }; let footer_packed = pack_hint_lines(&footer_hints, inner.width as usize); let footer_height = if inner.height > header_height { (footer_packed.len() as u16).max(1) } else { 0 }; let field_height = inner.height.saturating_sub(header_height + footer_height); let layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(header_height), Constraint::Length(field_height), Constraint::Length(footer_height), ]) .split(inner); let subtitle = t!("settings.subtitle"); let header = Paragraph::new(Line::from(Span::styled( format!(" {subtitle}"), Style::default().fg(colors.text_pending()), ))); header.render(layout[0], frame.buffer_mut()); let row_height = 2u16; let visible_rows = (layout[1].height / row_height).max(1) as usize; let max_start = fields.len().saturating_sub(visible_rows); let start = app .settings_selected .saturating_sub(visible_rows.saturating_sub(1)) .min(max_start); let end = (start + visible_rows).min(fields.len()); let visible_fields = &fields[start..end]; let field_layout = Layout::default() .direction(Direction::Vertical) .constraints( visible_fields .iter() .map(|_| Constraint::Length(row_height)) .collect::>(), ) .split(layout[1]); for (row, (item, label, value)) in visible_fields.iter().enumerate() { let i = start + row; let is_selected = i == app.settings_selected; let indicator = if is_selected { " > " } else { " " }; let label_text = format!("{indicator}{label}:"); let is_button = item.is_action_button(); let value_text = if is_button { format!(" [ {value} ]") } else { format!(" < {value} >") }; let label_style = Style::default() .fg(if is_selected { colors.accent() } else { colors.fg() }) .add_modifier(if is_selected { Modifier::BOLD } else { Modifier::empty() }); let value_style = Style::default().fg(if is_selected { colors.focused_key() } else { colors.text_pending() }); let is_editing_this_path = is_selected && item.is_path_field() && app.is_editing_field(i); let lines = if item.is_path_field() { 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 path_spans = match cursor_ch { Some(ch) => vec![ Span::styled(format!(" {before}"), value_style), Span::styled(ch.to_string(), cursor_style), Span::styled(after.to_string(), value_style), ], None => vec![ Span::styled(format!(" {before}"), value_style), Span::styled(" ", cursor_style), ], }; vec![ Line::from(Span::styled( format!("{indicator}{label}: (editing)"), label_style, )), Line::from(path_spans), ] } else { vec![ Line::from(Span::styled(label_text, label_style)), Line::from(Span::styled(format!(" {value}"), value_style)), ] } } else { vec![ Line::from(Span::styled(label_text, label_style)), Line::from(Span::styled(format!(" {value}"), value_style)), ] } } else { vec![ Line::from(Span::styled(label_text, label_style)), Line::from(Span::styled(value_text, value_style)), ] }; Paragraph::new(lines).render(field_layout[row], frame.buffer_mut()); } let footer_lines: Vec = footer_packed .into_iter() .map(|line| Line::from(Span::styled(line, Style::default().fg(colors.accent())))) .collect(); Paragraph::new(footer_lines) .wrap(Wrap { trim: false }) .render(layout[2], frame.buffer_mut()); // --- Overlay dialogs (rendered on top of settings) --- // Status message takes highest priority if let Some(ref msg) = app.settings_status_message { let border_color = match msg.kind { StatusKind::Success => colors.accent(), StatusKind::Error => colors.error(), }; let title_t = match msg.kind { StatusKind::Success => t!("settings.success_title"), StatusKind::Error => t!("settings.error_title"), }; let title = title_t.as_ref(); let dialog_width = 56u16.min(area.width.saturating_sub(4)); let dialog_height = 6u16; 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 dialog = Paragraph::new(vec![ Line::from(""), Line::from(Span::styled( format!(" {} ", msg.text), Style::default().fg(colors.fg()), )), Line::from(""), Line::from(Span::styled( format!(" {}", t!("settings.press_any_key")), Style::default().fg(colors.text_pending()), )), ]) .wrap(Wrap { trim: false }) .style(Style::default().bg(colors.bg())) .block( Block::bordered() .title(title) .border_style(Style::default().fg(border_color)) .style(Style::default().bg(colors.bg())), ); frame.render_widget(dialog, dialog_area); } else if app.settings_export_conflict { let dialog_width = 52u16.min(area.width.saturating_sub(4)); let dialog_height = 6u16; 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 file_exists_msg = t!("settings.file_exists"); let overwrite_rename = t!("settings.overwrite_rename"); let dialog = Paragraph::new(vec![ Line::from(""), Line::from(Span::styled( format!(" {file_exists_msg}"), Style::default().fg(colors.fg()), )), Line::from(""), Line::from(Span::styled( format!(" {overwrite_rename}"), Style::default().fg(colors.text_pending()), )), ]) .style(Style::default().bg(colors.bg())) .block({ let fe_title = t!("settings.file_exists_title"); Block::bordered() .title(fe_title.to_string()) .border_style(Style::default().fg(colors.error())) .style(Style::default().bg(colors.bg())) }); frame.render_widget(dialog, dialog_area); } else if app.settings_confirm_import { let dialog_width = 52u16.min(area.width.saturating_sub(4)); let dialog_height = 7u16; 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 erase_warning = t!("settings.erase_warning"); let export_first = t!("settings.export_first"); let proceed_yn = t!("settings.proceed_yn"); let dialog = Paragraph::new(vec![ Line::from(""), Line::from(Span::styled( format!(" {erase_warning}"), Style::default().fg(colors.fg()), )), Line::from(Span::styled( format!(" {export_first}"), Style::default().fg(colors.text_pending()), )), Line::from(""), Line::from(Span::styled( format!(" {proceed_yn}"), Style::default().fg(colors.fg()), )), ]) .style(Style::default().bg(colors.bg())) .block({ let ci_title = t!("settings.confirm_import_title"); Block::bordered() .title(ci_title.to_string()) .border_style(Style::default().fg(colors.error())) .style(Style::default().bg(colors.bg())) }); frame.render_widget(dialog, dialog_area); } } fn render_dictionary_language_select(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; let centered = ui::layout::centered_rect(60, 70, area); let sel_title = t!("select.dictionary_language_title"); let block = Block::bordered() .title(sel_title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); block.render(centered, frame.buffer_mut()); let options = language_packs(); let h_nav = ui::hint::hint(ui::hint::K_UP_DOWN_PGUP_PGDN, t!("select.hint_navigate").as_ref()); let h_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("select.hint_confirm").as_ref()); let h_back = ui::hint::hint(ui::hint::K_Q_ESC, t!("select.hint_back").as_ref()); let footer_hints: Vec<&str> = vec![h_nav.as_str(), h_confirm.as_str(), h_back.as_str()]; let support_notice_t = t!("select.language_resets_layout"); let support_notice = support_notice_t.as_ref(); let width = inner.width as usize; let hint_lines_vec = pack_hint_lines(&footer_hints, width); let hint_lines = hint_lines_vec.len(); let notice_lines = wrapped_line_count(support_notice, width); let total_height = inner.height as usize; let show_notice = total_height >= hint_lines + notice_lines + 3; let desired_footer_height = hint_lines + if show_notice { notice_lines } else { 0 }; let footer_height = desired_footer_height.min(total_height.saturating_sub(1)) as u16; let (list_area, footer_area) = if footer_height > 0 { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(footer_height)]) .split(inner); (chunks[0], Some(chunks[1])) } else { (inner, None) }; let viewport_height = (list_area.height as usize).saturating_sub(2).max(1); let scroll = app.dictionary_language_scroll; let visible_end = (scroll + viewport_height).min(options.len()); let mut lines: Vec = Vec::new(); if scroll > 0 { let more_above = t!("select.more_above", count = scroll); lines.push(Line::from(Span::styled( format!(" {more_above}"), Style::default().fg(colors.text_pending()), ))); } else { lines.push(Line::from("")); } for i in scroll..visible_end { let pack = options[i]; let is_selected = i == app.dictionary_language_selected; let is_current = pack.language_key == app.config.dictionary_language; let indicator = if is_selected { " > " } else { " " }; let current_marker_t = if is_current { t!("select.current") } else { std::borrow::Cow::Borrowed("") }; let current_marker = current_marker_t.as_ref(); let is_disabled = is_dictionary_language_disabled(app, pack.language_key); let default_layout = default_keyboard_layout_for_language(pack.language_key).unwrap_or("unknown"); let availability = if is_disabled { t!("select.disabled").to_string() } else { t!("select.enabled_default", layout = default_layout).to_string() }; let name_style = if is_disabled { Style::default().fg(colors.text_pending()) } else if is_selected { Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD) } else { Style::default().fg(colors.fg()) }; let status_style = Style::default() .fg(colors.text_pending()) .add_modifier(Modifier::DIM); lines.push(Line::from(vec![ Span::styled( format!( "{indicator}{} ({}){current_marker}", pack.display_name, pack.language_key ), name_style, ), Span::styled(availability, status_style), ])); } if visible_end < options.len() { let more_below = t!("select.more_below", count = options.len() - visible_end); lines.push(Line::from(Span::styled( format!(" {more_below}"), Style::default().fg(colors.text_pending()), ))); } else { lines.push(Line::from("")); } Paragraph::new(lines).render(list_area, frame.buffer_mut()); if let Some(footer) = footer_area { let mut footer_lines: Vec = hint_lines_vec .iter() .map(|line| { Line::from(Span::styled( line.clone(), Style::default().fg(colors.text_pending()), )) }) .collect(); if show_notice { footer_lines.push(Line::from(Span::styled( support_notice, Style::default().fg(colors.text_pending()), ))); } Paragraph::new(footer_lines) .wrap(Wrap { trim: false }) .render(footer, frame.buffer_mut()); } } fn render_ui_language_select(frame: &mut ratatui::Frame, app: &App) { use crate::i18n::t; let area = frame.area(); let colors = &app.theme.colors; let centered = ui::layout::centered_rect(50, 50, area); let title = t!("select.ui_language_title"); let block = Block::bordered() .title(title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); block.render(centered, frame.buffer_mut()); let locales = i18n::SUPPORTED_UI_LOCALES; let hint_back = t!("select.hint_back"); let hint_confirm = t!("select.hint_confirm"); let footer_hints = [hint_back.as_ref(), hint_confirm.as_ref()]; let footer_lines = pack_hint_lines(&footer_hints, inner.width as usize); let footer_h = footer_lines.len().max(1) as u16; let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(footer_h)]) .split(inner); let mut lines: Vec = Vec::new(); for (i, &locale) in locales.iter().enumerate() { let is_selected = i == app.ui_language_selected; let is_current = locale == app.config.ui_language; let autonym = find_language_pack(locale) .map(|p| p.autonym) .unwrap_or(locale); let suffix = if is_current { t!("select.current").to_string() } else { String::new() }; let indicator = if is_selected { "> " } else { " " }; let label = format!("{indicator}{autonym}{suffix}"); let style = if is_selected { Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD) } else { Style::default().fg(colors.fg()) }; lines.push(Line::from(Span::styled(label, style))); } let list = Paragraph::new(lines); list.render(chunks[0], frame.buffer_mut()); let footer: Vec = footer_lines .into_iter() .map(|l| Line::from(Span::styled(l, Style::default().fg(colors.text_pending())))) .collect(); Paragraph::new(footer).render(chunks[1], frame.buffer_mut()); } fn render_keyboard_layout_select(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; let centered = ui::layout::centered_rect(60, 70, area); let sel_title = t!("select.keyboard_layout_title"); let block = Block::bordered() .title(sel_title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); block.render(centered, frame.buffer_mut()); let options = keyboard::model::KeyboardModel::supported_layout_keys(); let h_nav = ui::hint::hint(ui::hint::K_UP_DOWN_PGUP_PGDN, t!("select.hint_navigate").as_ref()); let h_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("select.hint_confirm").as_ref()); let h_back = ui::hint::hint(ui::hint::K_Q_ESC, t!("select.hint_back").as_ref()); let footer_hints: Vec<&str> = vec![h_nav.as_str(), h_confirm.as_str(), h_back.as_str()]; let support_notice_t = t!("select.layout_no_language_change"); let support_notice = support_notice_t.as_ref(); let width = inner.width as usize; let hint_lines_vec = pack_hint_lines(&footer_hints, width); let hint_lines = hint_lines_vec.len(); let notice_lines = wrapped_line_count(support_notice, width); let total_height = inner.height as usize; let show_notice = total_height >= hint_lines + notice_lines + 3; let desired_footer_height = hint_lines + if show_notice { notice_lines } else { 0 }; let footer_height = desired_footer_height.min(total_height.saturating_sub(1)) as u16; let (list_area, footer_area) = if footer_height > 0 { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(footer_height)]) .split(inner); (chunks[0], Some(chunks[1])) } else { (inner, None) }; let viewport_height = (list_area.height as usize).saturating_sub(2).max(1); let scroll = app.keyboard_layout_scroll; let visible_end = (scroll + viewport_height).min(options.len()); let mut lines: Vec = Vec::new(); if scroll > 0 { let more_above = t!("select.more_above", count = scroll); lines.push(Line::from(Span::styled( format!(" {more_above}"), Style::default().fg(colors.text_pending()), ))); } else { lines.push(Line::from("")); } for i in scroll..visible_end { let key = options[i]; let is_selected = i == app.keyboard_layout_selected; let is_current = key == app.config.keyboard_layout; let indicator = if is_selected { " > " } else { " " }; let current_marker_t = if is_current { t!("select.current") } else { std::borrow::Cow::Borrowed("") }; let current_marker = current_marker_t.as_ref(); let validation = validate_language_layout_pair(&app.config.dictionary_language, key); let (availability, is_disabled) = match validation { Ok(CapabilityState::Enabled) => (t!("select.enabled").to_string(), false), Ok(CapabilityState::Disabled) => (t!("select.disabled").to_string(), true), Err(crate::l10n::language_pack::LanguageLayoutValidationError::UnsupportedLanguageLayoutPair { .. }) => { (t!("select.disabled").to_string(), true) } Err(crate::l10n::language_pack::LanguageLayoutValidationError::LanguageBlockedBySupportLevel(_)) => { (t!("select.disabled_blocked").to_string(), true) } Err(crate::l10n::language_pack::LanguageLayoutValidationError::UnknownLanguage(_)) | Err(crate::l10n::language_pack::LanguageLayoutValidationError::UnknownLayout(_)) => { (t!("select.disabled").to_string(), true) } }; let name_style = if is_disabled { Style::default().fg(colors.text_pending()) } else if is_selected { Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD) } else { Style::default().fg(colors.fg()) }; let status_style = Style::default() .fg(colors.text_pending()) .add_modifier(Modifier::DIM); lines.push(Line::from(vec![ Span::styled(format!("{indicator}{key}{current_marker}"), name_style), Span::styled(availability, status_style), ])); } if visible_end < options.len() { let more_below = t!("select.more_below", count = options.len() - visible_end); lines.push(Line::from(Span::styled( format!(" {more_below}"), Style::default().fg(colors.text_pending()), ))); } else { lines.push(Line::from("")); } Paragraph::new(lines).render(list_area, frame.buffer_mut()); if let Some(footer) = footer_area { let mut footer_lines: Vec = hint_lines_vec .iter() .map(|line| { Line::from(Span::styled( line.clone(), Style::default().fg(colors.text_pending()), )) }) .collect(); if show_notice { footer_lines.push(Line::from(Span::styled( support_notice, Style::default().fg(colors.text_pending()), ))); } Paragraph::new(footer_lines) .wrap(Wrap { trim: false }) .render(footer, frame.buffer_mut()); } } fn render_code_language_select(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; let centered = ui::layout::centered_rect(50, 70, area); let sel_title = t!("select.code_language_title"); let block = Block::bordered() .title(sel_title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); block.render(centered, frame.buffer_mut()); let options = code_language_options(); let cache_dir = &app.config.code_download_dir; let h_nav = ui::hint::hint(ui::hint::K_UP_DOWN_PGUP_PGDN, t!("select.hint_navigate").as_ref()); let h_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("select.hint_confirm").as_ref()); let h_back = ui::hint::hint(ui::hint::K_Q_ESC, t!("select.hint_back").as_ref()); let footer_hints: Vec<&str> = vec![h_nav.as_str(), h_confirm.as_str(), h_back.as_str()]; let disabled_notice_t = t!("select.disabled_sources_notice"); let disabled_notice = disabled_notice_t.as_ref(); let has_disabled = !app.config.code_downloads_enabled && options .iter() .any(|(key, _)| is_code_language_disabled(app, key)); let width = inner.width as usize; let hint_lines_vec = pack_hint_lines(&footer_hints, width); let hint_lines = hint_lines_vec.len(); let notice_lines = wrapped_line_count(disabled_notice, width); let total_height = inner.height as usize; let show_notice = has_disabled && total_height >= hint_lines + notice_lines + 3; let desired_footer_height = hint_lines + if show_notice { notice_lines } else { 0 }; let footer_height = desired_footer_height.min(total_height.saturating_sub(1)) as u16; let (list_area, footer_area) = if footer_height > 0 { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(footer_height)]) .split(inner); (chunks[0], Some(chunks[1])) } else { (inner, None) }; let viewport_height = (list_area.height as usize).saturating_sub(2).max(1); let scroll = app.code_language_scroll; let mut lines: Vec = Vec::new(); // Show scroll indicator at top if scrolled down if scroll > 0 { let more_above = t!("select.more_above", count = scroll); lines.push(Line::from(Span::styled( format!(" {more_above}"), Style::default().fg(colors.text_pending()), ))); } else { lines.push(Line::from("")); } let visible_end = (scroll + viewport_height).min(options.len()); for i in scroll..visible_end { let (key, display) = &options[i]; let is_selected = i == app.code_language_selected; let is_current = *key == app.config.code_language; let is_disabled = is_code_language_disabled(app, key); let indicator = if is_selected { " > " } else { " " }; let current_marker_t = if is_current { t!("select.current") } else { std::borrow::Cow::Borrowed("") }; let current_marker = current_marker_t.as_ref(); // Determine availability label let availability = if *key == "all" { String::new() } else if let Some(lang) = language_by_key(key) { if lang.has_builtin { t!("select.built_in").to_string() } else if is_language_cached(cache_dir, key) { t!("select.cached").to_string() } else if is_disabled { t!("select.disabled_download").to_string() } else { t!("select.download_required").to_string() } } else { String::new() }; let name_style = if is_disabled { Style::default().fg(colors.text_pending()) } else if is_selected { Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD) } else { Style::default().fg(colors.fg()) }; let status_style = Style::default() .fg(colors.text_pending()) .add_modifier(Modifier::DIM); let mut spans = vec![Span::styled( format!("{indicator}{display}{current_marker}"), name_style, )]; if !availability.is_empty() { spans.push(Span::styled(availability, status_style)); } lines.push(Line::from(spans)); } // Show scroll indicator at bottom if more items below if visible_end < options.len() { let more_below = t!("select.more_below", count = options.len() - visible_end); lines.push(Line::from(Span::styled( format!(" {more_below}"), Style::default().fg(colors.text_pending()), ))); } else { lines.push(Line::from("")); } Paragraph::new(lines).render(list_area, frame.buffer_mut()); if let Some(footer) = footer_area { let mut footer_lines: Vec = hint_lines_vec .iter() .map(|line| { Line::from(Span::styled( line.clone(), Style::default().fg(colors.text_pending()), )) }) .collect(); if show_notice { footer_lines.push(Line::from(Span::styled( disabled_notice, Style::default().fg(colors.text_pending()), ))); } Paragraph::new(footer_lines) .wrap(Wrap { trim: false }) .render(footer, frame.buffer_mut()); } } fn render_passage_book_select(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; let centered = ui::layout::centered_rect(60, 70, area); let sel_title = t!("select.passage_source_title"); let block = Block::bordered() .title(sel_title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); block.render(centered, frame.buffer_mut()); let options = passage_options(); let h_nav = ui::hint::hint(ui::hint::K_UP_DOWN, t!("select.hint_navigate").as_ref()); let h_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("select.hint_confirm").as_ref()); let h_back = ui::hint::hint(ui::hint::K_Q_ESC, t!("select.hint_back").as_ref()); let footer_hints: Vec<&str> = vec![h_nav.as_str(), h_confirm.as_str(), h_back.as_str()]; let disabled_notice_t = t!("select.disabled_sources_notice"); let disabled_notice = disabled_notice_t.as_ref(); let has_disabled = !app.config.passage_downloads_enabled && options .iter() .any(|(key, _)| is_passage_option_disabled(app, key)); let width = inner.width as usize; let hint_lines_vec = pack_hint_lines(&footer_hints, width); let hint_lines = hint_lines_vec.len(); let notice_lines = wrapped_line_count(disabled_notice, width); let total_height = inner.height as usize; let show_notice = has_disabled && total_height >= hint_lines + notice_lines + 3; let desired_footer_height = hint_lines + if show_notice { notice_lines } else { 0 }; let footer_height = desired_footer_height.min(total_height.saturating_sub(1)) as u16; let (list_area, footer_area) = if footer_height > 0 { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(footer_height)]) .split(inner); (chunks[0], Some(chunks[1])) } else { (inner, None) }; let viewport_height = list_area.height as usize; let start = app .passage_book_selected .saturating_sub(viewport_height.saturating_sub(1)); let end = (start + viewport_height).min(options.len()); let mut lines: Vec = vec![]; for (i, (key, label)) in options.iter().enumerate().skip(start).take(end - start) { let is_selected = i == app.passage_book_selected; let is_disabled = is_passage_option_disabled(app, key); let indicator = if is_selected { " > " } else { " " }; let availability = if *key == "all" { String::new() } else if *key == "builtin" { t!("select.built_in").to_string() } else if is_book_cached(&app.config.passage_download_dir, key) { t!("select.cached").to_string() } else if is_disabled { t!("select.disabled_download").to_string() } else { t!("select.download_required").to_string() }; let name_style = if is_disabled { Style::default().fg(colors.text_pending()) } else if is_selected { Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD) } else { Style::default().fg(colors.fg()) }; let status_style = Style::default() .fg(colors.text_pending()) .add_modifier(Modifier::DIM); let mut spans = vec![Span::styled( format!("{indicator}[{}] {label}", i + 1), name_style, )]; if !availability.is_empty() { spans.push(Span::styled(availability, status_style)); } lines.push(Line::from(spans)); } Paragraph::new(lines).render(list_area, frame.buffer_mut()); if let Some(footer) = footer_area { let mut footer_lines: Vec = hint_lines_vec .iter() .map(|line| { Line::from(Span::styled( line.clone(), Style::default().fg(colors.text_pending()), )) }) .collect(); if show_notice { footer_lines.push(Line::from(Span::styled( disabled_notice, Style::default().fg(colors.text_pending()), ))); } Paragraph::new(footer_lines) .wrap(Wrap { trim: false }) .render(footer, frame.buffer_mut()); } } fn render_passage_intro(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; let centered = ui::layout::centered_rect(75, 80, area); let intro_title = t!("intro.passage_title"); let block = Block::bordered() .title(intro_title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); block.render(centered, frame.buffer_mut()); let paragraphs_value = if app.passage_intro_paragraph_limit == 0 { t!("intro.whole_book").to_string() } else { app.passage_intro_paragraph_limit.to_string() }; let enable_label = t!("intro.enable_downloads"); let dir_label = t!("intro.download_dir"); let para_label = t!("intro.paragraphs_per_book"); let start_label = t!("intro.start_passage_drill"); let confirm_label = t!("intro.confirm"); let fields = vec![ ( enable_label.as_ref(), if app.passage_intro_downloads_enabled { t!("settings.on").to_string() } else { t!("settings.off").to_string() }, ), (dir_label.as_ref(), app.passage_intro_download_dir.clone()), (para_label.as_ref(), paragraphs_value), (start_label.as_ref(), confirm_label.to_string()), ]; let instr1 = t!("intro.passage_instructions_1"); let instr2 = t!("intro.passage_instructions_2"); let instr3 = t!("intro.passage_instructions_3"); let mut lines = vec![ Line::from(Span::styled( instr1.as_ref(), Style::default() .fg(colors.fg()) .add_modifier(Modifier::BOLD), )), Line::from(Span::styled( instr2.as_ref(), Style::default().fg(colors.text_pending()), )), Line::from(Span::styled( instr3.as_ref(), Style::default().fg(colors.text_pending()), )), Line::from(""), ]; for (i, (label, value)) in fields.iter().enumerate() { let is_selected = i == app.passage_intro_selected; let indicator = if is_selected { " > " } else { " " }; let label_style = if is_selected { Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD) } else { Style::default().fg(colors.fg()) }; let value_style = if is_selected { Style::default().fg(colors.focused_key()) } else { Style::default().fg(colors.text_pending()) }; lines.push(Line::from(Span::styled( format!("{indicator}{label}"), label_style, ))); if i == 1 { lines.push(Line::from(Span::styled(format!(" {value}"), value_style))); } else if i == 3 { lines.push(Line::from(Span::styled( format!(" [{value}]"), value_style, ))); } else { lines.push(Line::from(Span::styled( format!(" < {value} >"), value_style, ))); } lines.push(Line::from("")); } if app.passage_intro_downloading { let total_books = app.passage_intro_download_total.max(1); let done_books = app.passage_intro_downloaded.min(total_books); let total_bytes = app.passage_intro_download_bytes_total; let done_bytes = app .passage_intro_download_bytes .min(total_bytes.max(app.passage_intro_download_bytes)); let width = 30usize; let fill = if total_bytes > 0 { ((done_bytes as usize).saturating_mul(width)) / (total_bytes as usize) } else { 0 }; let bar = format!( "{}{}", "=".repeat(fill), " ".repeat(width.saturating_sub(fill)) ); let progress_text = if total_bytes > 0 { format!(" {}", t!("intro.downloading_book_progress", bar = bar, downloaded = done_bytes, total = total_bytes)) } else { format!(" {}", t!("intro.downloading_book_bytes", bytes = done_bytes)) }; lines.push(Line::from(Span::styled( progress_text, Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), ))); if !app.passage_intro_current_book.is_empty() { let current_text = t!("intro.current_book", name = app.passage_intro_current_book.as_str(), done = done_books.saturating_add(1).min(total_books), total = total_books ); lines.push(Line::from(Span::styled( format!(" {current_text}"), Style::default().fg(colors.text_pending()), ))); } } let hint_lines = if app.passage_intro_downloading { Vec::new() } else { let ih_nav = ui::hint::hint(ui::hint::K_UP_DOWN, t!("intro.hint_navigate").as_ref()); let ih_adj = ui::hint::hint(ui::hint::K_LEFT_RIGHT, t!("intro.hint_adjust").as_ref()); let ih_edit = ui::hint::hint(ui::hint::K_TYPE_BACKSPACE, t!("intro.hint_edit").as_ref()); let ih_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("intro.hint_confirm").as_ref()); let ih_cancel = ui::hint::hint(ui::hint::K_Q_ESC, t!("intro.hint_cancel").as_ref()); let hints: Vec<&str> = vec![ih_nav.as_str(), ih_adj.as_str(), ih_edit.as_str(), ih_confirm.as_str(), ih_cancel.as_str()]; pack_hint_lines( &hints, inner.width as usize, ) }; let footer_height = if hint_lines.is_empty() { 0 } else { (hint_lines.len() + 1) as u16 // add spacer line above hints }; let (content_area, footer_area) = if footer_height > 0 && footer_height < inner.height { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(footer_height)]) .split(inner); (chunks[0], Some(chunks[1])) } else { (inner, None) }; Paragraph::new(lines).render(content_area, frame.buffer_mut()); if let Some(footer) = footer_area { let mut footer_lines = vec![Line::from("")]; footer_lines.extend(hint_lines.into_iter().map(|hint| { Line::from(Span::styled( hint, Style::default().fg(colors.text_pending()), )) })); Paragraph::new(footer_lines) .wrap(Wrap { trim: false }) .render(footer, frame.buffer_mut()); } } fn render_passage_download_progress(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; let centered = ui::layout::centered_rect(60, 35, area); let dl_title = t!("intro.download_passage_title"); let block = Block::bordered() .title(dl_title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); block.render(centered, frame.buffer_mut()); let total_bytes = app.passage_intro_download_bytes_total; let done_bytes = app .passage_intro_download_bytes .min(total_bytes.max(app.passage_intro_download_bytes)); let width = 36usize; let fill = if total_bytes > 0 { ((done_bytes as usize).saturating_mul(width)) / (total_bytes as usize) } else { 0 }; let bar = format!( "{}{}", "=".repeat(fill), " ".repeat(width.saturating_sub(fill)) ); let book_name = if app.passage_intro_current_book.is_empty() { t!("intro.preparing_download").to_string() } else { app.passage_intro_current_book.clone() }; let book_label = t!("intro.book_label", name = book_name); let lines = vec![ Line::from(Span::styled( book_label.as_ref(), Style::default() .fg(colors.fg()) .add_modifier(Modifier::BOLD), )), Line::from(""), Line::from(Span::styled( if total_bytes > 0 { t!("intro.progress_bytes", name = bar, downloaded = done_bytes, total = total_bytes).to_string() } else { t!("intro.downloaded_bytes", bytes = done_bytes).to_string() }, Style::default().fg(colors.accent()), )), ]; Paragraph::new(lines).render(inner, frame.buffer_mut()); } fn render_code_intro(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; let centered = ui::layout::centered_rect(75, 80, area); let intro_title = t!("intro.code_title"); let block = Block::bordered() .title(intro_title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); block.render(centered, frame.buffer_mut()); let snippets_value = if app.code_intro_snippets_per_repo == 0 { t!("intro.unlimited").to_string() } else { app.code_intro_snippets_per_repo.to_string() }; let enable_label = t!("intro.enable_downloads"); let dir_label = t!("intro.download_dir"); let snippets_label = t!("intro.snippets_per_repo"); let start_label = t!("intro.start_code_drill"); let confirm_label = t!("intro.confirm"); let fields = vec![ ( enable_label.as_ref(), if app.code_intro_downloads_enabled { t!("settings.on").to_string() } else { t!("settings.off").to_string() }, ), (dir_label.as_ref(), app.code_intro_download_dir.clone()), (snippets_label.as_ref(), snippets_value), (start_label.as_ref(), confirm_label.to_string()), ]; let instr1 = t!("intro.code_instructions_1"); let instr2 = t!("intro.code_instructions_2"); let instr3 = t!("intro.code_instructions_3"); let mut lines = vec![ Line::from(Span::styled( instr1.as_ref(), Style::default() .fg(colors.fg()) .add_modifier(Modifier::BOLD), )), Line::from(Span::styled( instr2.as_ref(), Style::default().fg(colors.text_pending()), )), Line::from(Span::styled( instr3.as_ref(), Style::default().fg(colors.text_pending()), )), Line::from(""), ]; for (i, (label, value)) in fields.iter().enumerate() { let is_selected = i == app.code_intro_selected; let indicator = if is_selected { " > " } else { " " }; let label_style = if is_selected { Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD) } else { Style::default().fg(colors.fg()) }; let value_style = if is_selected { Style::default().fg(colors.focused_key()) } else { Style::default().fg(colors.text_pending()) }; lines.push(Line::from(Span::styled( format!("{indicator}{label}"), label_style, ))); if i == 1 { lines.push(Line::from(Span::styled(format!(" {value}"), value_style))); } else if i == 3 { lines.push(Line::from(Span::styled( format!(" [{value}]"), value_style, ))); } else { lines.push(Line::from(Span::styled( format!(" < {value} >"), value_style, ))); } lines.push(Line::from("")); } if app.code_intro_downloading { let total_repos = app.code_intro_download_total.max(1); let done_repos = app.code_intro_downloaded.min(total_repos); let total_bytes = app.code_intro_download_bytes_total; let done_bytes = app .code_intro_download_bytes .min(total_bytes.max(app.code_intro_download_bytes)); let width = 30usize; let fill = if total_bytes > 0 { ((done_bytes as usize).saturating_mul(width)) / (total_bytes as usize) } else { 0 }; let bar = format!( "{}{}", "=".repeat(fill), " ".repeat(width.saturating_sub(fill)) ); let progress_text = if total_bytes > 0 { format!(" {}", t!("intro.downloading_code_progress", bar = bar, downloaded = done_bytes, total = total_bytes)) } else { format!(" {}", t!("intro.downloading_code_bytes", bytes = done_bytes)) }; lines.push(Line::from(Span::styled( progress_text, Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), ))); if !app.code_intro_current_repo.is_empty() { let current_text = t!("intro.current_repo", name = app.code_intro_current_repo.as_str(), done = done_repos.saturating_add(1).min(total_repos), total = total_repos ); lines.push(Line::from(Span::styled( format!(" {current_text}"), Style::default().fg(colors.text_pending()), ))); } } let hint_lines = if app.code_intro_downloading { Vec::new() } else { let ih_nav = ui::hint::hint(ui::hint::K_UP_DOWN, t!("intro.hint_navigate").as_ref()); let ih_adj = ui::hint::hint(ui::hint::K_LEFT_RIGHT, t!("intro.hint_adjust").as_ref()); let ih_edit = ui::hint::hint(ui::hint::K_TYPE_BACKSPACE, t!("intro.hint_edit").as_ref()); let ih_confirm = ui::hint::hint(ui::hint::K_ENTER, t!("intro.hint_confirm").as_ref()); let ih_cancel = ui::hint::hint(ui::hint::K_Q_ESC, t!("intro.hint_cancel").as_ref()); let hints: Vec<&str> = vec![ih_nav.as_str(), ih_adj.as_str(), ih_edit.as_str(), ih_confirm.as_str(), ih_cancel.as_str()]; pack_hint_lines( &hints, inner.width as usize, ) }; let footer_height = if hint_lines.is_empty() { 0 } else { (hint_lines.len() + 1) as u16 // add spacer line above hints }; let (content_area, footer_area) = if footer_height > 0 && footer_height < inner.height { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(footer_height)]) .split(inner); (chunks[0], Some(chunks[1])) } else { (inner, None) }; Paragraph::new(lines).render(content_area, frame.buffer_mut()); if let Some(footer) = footer_area { let mut footer_lines = vec![Line::from("")]; footer_lines.extend(hint_lines.into_iter().map(|hint| { Line::from(Span::styled( hint, Style::default().fg(colors.text_pending()), )) })); Paragraph::new(footer_lines) .wrap(Wrap { trim: false }) .render(footer, frame.buffer_mut()); } } fn render_code_download_progress(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; let centered = ui::layout::centered_rect(60, 35, area); let dl_title = t!("intro.download_code_title"); let block = Block::bordered() .title(dl_title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); block.render(centered, frame.buffer_mut()); let total_bytes = app.code_intro_download_bytes_total; let done_bytes = app .code_intro_download_bytes .min(total_bytes.max(app.code_intro_download_bytes)); let width = 36usize; let fill = if total_bytes > 0 { ((done_bytes as usize).saturating_mul(width)) / (total_bytes as usize) } else { 0 }; let bar = format!( "{}{}", "=".repeat(fill), " ".repeat(width.saturating_sub(fill)) ); let repo_name = if app.code_intro_current_repo.is_empty() { t!("intro.preparing_download").to_string() } else { app.code_intro_current_repo.clone() }; let repo_label = t!("intro.repo_label", name = repo_name); let cancel_hint = t!("intro.hint_cancel"); let lines = vec![ Line::from(Span::styled( repo_label.as_ref(), Style::default() .fg(colors.fg()) .add_modifier(Modifier::BOLD), )), Line::from(""), Line::from(Span::styled( if total_bytes > 0 { t!("intro.progress_bytes", name = bar, downloaded = done_bytes, total = total_bytes).to_string() } else { t!("intro.downloaded_bytes", bytes = done_bytes).to_string() }, Style::default().fg(colors.accent()), )), Line::from(""), Line::from(Span::styled( format!(" {}", cancel_hint.as_ref()), Style::default().fg(colors.text_pending()), )), ]; Paragraph::new(lines).render(inner, frame.buffer_mut()); } 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, &app.ranked_key_stats, app.skill_tree_selected, app.skill_tree_detail_scroll, app.theme, ); frame.render_widget(widget, centered); if let Some(branch_id) = app.skill_tree_confirm_unlock { let sentence_one_t = t!("skill_tree.unlock_msg_1"); let sentence_one = sentence_one_t.as_ref(); let sentence_two_t = t!("skill_tree.unlock_msg_2"); let sentence_two = sentence_two_t.as_ref(); let branch_name = engine::skill_tree::get_branch_definition(branch_id).display_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 confirm_title = t!("stats.confirm_title"); let block = Block::bordered() .title(confirm_title.as_ref()) .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 unlock_prompt = t!("skill_tree.confirm_unlock", branch = branch_name); let body = Paragraph::new(vec![ Line::from(""), Line::from(Span::styled( unlock_prompt.to_string(), 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_yn = t!("skill_tree.confirm_yn"); let confirm_lines = if prompt_block_height > 1 { vec![ Line::from(""), Line::from(Span::styled( confirm_yn.as_ref(), Style::default().fg(colors.fg()), )), ] } else { vec![Line::from(Span::styled( confirm_yn.as_ref(), 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) { match key.code { KeyCode::Esc => app.go_to_menu(), KeyCode::Char('q') if app.keyboard_explorer_selected.is_none() => app.go_to_menu(), KeyCode::Char(ch) => { keyboard_explorer_select_key(app, ch); } KeyCode::Tab => { keyboard_explorer_select_key(app, '\t'); } KeyCode::Enter => { keyboard_explorer_select_key(app, '\n'); } KeyCode::Backspace => { keyboard_explorer_select_key(app, '\x08'); } _ => {} } } fn keyboard_explorer_select_key(app: &mut App, ch: char) { app.keyboard_explorer_selected = Some(ch); app.key_accuracy(ch, false); app.key_accuracy(ch, true); if app.shift_held && app.keyboard_model.shifted_to_base(ch).is_none() { app.shift_held = false; } } fn handle_keyboard_explorer_mouse(app: &mut App, mouse: MouseEvent) { if !matches!( mouse.kind, MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) ) { return; } let area = terminal_area(); let layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Length(8), Constraint::Min(3), Constraint::Length(1), ]) .split(area); let h_back = ui::hint::hint(ui::hint::K_ESC, t!("keyboard.hint_back").as_ref()); let footer_hints: Vec<&str> = vec![h_back.as_str()]; if hint_token_at(layout[3], &footer_hints, mouse.column, mouse.row).is_some() || point_in_rect(mouse.column, mouse.row, layout[3]) { app.go_to_menu(); return; } if point_in_rect(mouse.column, mouse.row, layout[1]) { if KeyboardDiagram::shift_at_position( layout[1], &app.keyboard_model, false, mouse.column, mouse.row, ) { app.shift_held = !app.shift_held; return; } if let Some(mut ch) = KeyboardDiagram::key_at_position( layout[1], &app.keyboard_model, false, mouse.column, mouse.row, ) { if app.shift_held && let Some(shifted) = app.keyboard_model.base_to_shifted(ch) { ch = shifted; } keyboard_explorer_select_key(app, ch); } } } fn render_keyboard_explorer(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; let layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // header Constraint::Length(8), // keyboard diagram Constraint::Min(3), // detail panel Constraint::Length(1), // footer ]) .split(area); // Header let kbd_title = t!("keyboard.title"); let kbd_hint_nav = t!("keyboard.subtitle"); let header_lines = vec![ Line::from(""), Line::from(Span::styled( kbd_title.as_ref(), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), )), Line::from(Span::styled( kbd_hint_nav.as_ref(), Style::default().fg(colors.text_pending()), )), ]; let header = Paragraph::new(header_lines).alignment(ratatui::layout::Alignment::Center); frame.render_widget(header, layout[0]); // Keyboard diagram let unlocked = app.skill_tree.unlocked_keys(DrillScope::Global); let kbd = KeyboardDiagram::new( None, &unlocked, &app.depressed_keys, app.theme, &app.keyboard_model, ) .selected_key(app.keyboard_explorer_selected) .shift_held(app.shift_held) .caps_lock(app.caps_lock); frame.render_widget(kbd, layout[1]); // Detail panel render_keyboard_detail_panel(frame, app, layout[2]); // Footer let kbd_back = ui::hint::hint(ui::hint::K_ESC, t!("keyboard.hint_back").as_ref()); let footer = Paragraph::new(Line::from(vec![Span::styled( format!(" {} ", kbd_back.as_str()), Style::default().fg(colors.text_pending()), )])); frame.render_widget(footer, layout[3]); } fn render_keyboard_detail_panel(frame: &mut ratatui::Frame, app: &App, area: Rect) { let colors = &app.theme.colors; let selected = match app.keyboard_explorer_selected { Some(ch) => ch, None => { let press_hint = t!("keyboard.press_key_hint"); let key_details_title = t!("keyboard.key_details"); let hint = Paragraph::new(Line::from(Span::styled( press_hint.as_ref(), Style::default().fg(colors.text_pending()), ))) .alignment(ratatui::layout::Alignment::Center) .block( Block::bordered() .border_style(Style::default().fg(colors.border())) .title(key_details_title.to_string()), ); frame.render_widget(hint, area); return; } }; // Build display name for title let display_name = key_display_name(selected); let title = if display_name.is_empty() { t!("keyboard.key_details_char", ch = selected).to_string() } else { t!("keyboard.key_details_name", name = display_name).to_string() }; let block = Block::bordered() .border_style(Style::default().fg(colors.border())) .title(Span::styled( title, Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), )); let inner = block.inner(area); frame.render_widget(block, area); let finger = app.keyboard_model.finger_for_char(selected); let is_shifted = selected.is_uppercase() || app.keyboard_model.shifted_to_base(selected).is_some(); let shift_guidance = if is_shifted { if finger.hand == Hand::Left { t!("milestones.hold_right_shift").to_string() } else { t!("milestones.hold_left_shift").to_string() } } else { t!("keyboard.shift_no").to_string() }; let unlocked_keys = app.skill_tree.unlocked_keys(DrillScope::Global); let is_unlocked = unlocked_keys.contains(&selected); let focus_key = app .skill_tree .focused_key(DrillScope::Global, &app.ranked_key_stats); let in_focus = focus_key == Some(selected); let overall_stat = app.key_stats.get_stat(selected); let ranked_stat = app.ranked_key_stats.get_stat(selected); let overall_acc = app .explorer_accuracy_cache_overall .filter(|(key, _, _)| *key == selected); let ranked_acc = app .explorer_accuracy_cache_ranked .filter(|(key, _, _)| *key == selected); let fmt_avg_time = |stat: Option<&crate::engine::key_stats::KeyStat>| -> String { if let Some(stat) = stat { if stat.sample_count > 0 { return format!("{:.0}ms", stat.filtered_time_ms); } } t!("keyboard.no_data_short").to_string() }; let fmt_best_time = |stat: Option<&crate::engine::key_stats::KeyStat>| -> String { if let Some(stat) = stat { if stat.sample_count > 0 { let best = if stat.best_time_ms < f64::MAX { stat.best_time_ms } else { stat.filtered_time_ms }; return format!("{best:.0}ms"); } } t!("keyboard.no_data_short").to_string() }; let fmt_samples = |stat: Option<&crate::engine::key_stats::KeyStat>| -> String { stat.map(|s| s.sample_count.to_string()) .unwrap_or_else(|| "0".to_string()) }; let fmt_acc = |entry: Option<(char, usize, usize)>| -> String { if let Some((_, correct, total)) = entry { if total > 0 { let pct = (correct as f64 / total as f64) * 100.0; return format!("{:.1}% ({}/{})", pct, correct, total); } } t!("keyboard.no_data_short").to_string() }; let branch_info = find_key_branch(selected) .map(|(branch, level, pos)| (branch.display_name(), format!("{} (key #{pos})", level.display_name()))); // Ranked-only mastery display (same semantics as skill tree per-key progress) let ranked_conf = app.ranked_key_stats.get_confidence(selected).min(1.0); let mastery_bar_width = 10usize; let filled = (ranked_conf * mastery_bar_width as f64).round() as usize; let mastery_bar = format!( "{}{}", "\u{2588}".repeat(filled), "\u{2591}".repeat(mastery_bar_width.saturating_sub(filled)) ); let mastery_text = format!("{mastery_bar} {:>3.0}%", ranked_conf * 100.0); let mut left_col: Vec = vec![ format!("{}{}", t!("keyboard.finger_label"), finger.localized_description()), format!("{}{shift_guidance}", t!("keyboard.shift_label")), format!("{}{}", t!("keyboard.overall_avg_time"), fmt_avg_time(overall_stat)), format!("{}{}", t!("keyboard.overall_best_time"), fmt_best_time(overall_stat)), format!("{}{}", t!("keyboard.overall_samples"), fmt_samples(overall_stat)), format!("{}{}", t!("keyboard.overall_accuracy_label"), fmt_acc(overall_acc)), ]; let mut right_col: Vec = Vec::new(); if let Some((branch_name, level_name)) = branch_info { right_col.push(format!("{}{branch_name}", t!("keyboard.branch_label"))); right_col.push(format!("{}{level_name}", t!("keyboard.level_label"))); } else { right_col.push(t!("keyboard.built_in_key").to_string()); } let yes_t = t!("keyboard.yes"); let no_t = t!("keyboard.no"); right_col.push(format!( "{}{}", t!("keyboard.unlocked_label"), if is_unlocked { yes_t.as_ref() } else { no_t.as_ref() } )); let yes_t2 = t!("keyboard.yes"); let no_t2 = t!("keyboard.no"); right_col.push(format!( "{}{}", t!("keyboard.in_focus_label"), if in_focus { yes_t2.as_ref() } else { no_t2.as_ref() } )); if is_unlocked { right_col.push(format!("{}{mastery_text}", t!("keyboard.mastery_label"))); } else { right_col.push(format!("{}{}", t!("keyboard.mastery_label"), t!("keyboard.mastery_locked"))); } right_col.push(format!("{}{}", t!("keyboard.ranked_avg_time"), fmt_avg_time(ranked_stat))); right_col.push(format!("{}{}", t!("keyboard.ranked_best_time"), fmt_best_time(ranked_stat))); right_col.push(format!("{}{}", t!("keyboard.ranked_samples"), fmt_samples(ranked_stat))); right_col.push(format!("{}{}", t!("keyboard.ranked_accuracy_label"), fmt_acc(ranked_acc))); if left_col.is_empty() { left_col.push(t!("keyboard.no_data").to_string()); } if right_col.is_empty() { right_col.push(t!("keyboard.no_data").to_string()); } let mut lines: Vec = Vec::new(); let split_gap = 3usize; let left_width = inner.width.saturating_sub(split_gap as u16) as usize / 2; let right_width = inner.width as usize - left_width.saturating_sub(0) - split_gap; let row_count = left_col.len().max(right_col.len()); for i in 0..row_count { let left = left_col.get(i).map(String::as_str).unwrap_or(""); let right = right_col.get(i).map(String::as_str).unwrap_or(""); let mut left_fit: String = left.chars().take(left_width).collect(); if left_fit.len() < left_width { left_fit.push_str(&" ".repeat(left_width - left_fit.len())); } let right_fit: String = right.chars().take(right_width).collect(); lines.push(Line::from(vec![ Span::styled(" ", Style::default().fg(colors.fg())), Span::styled(left_fit, Style::default().fg(colors.fg())), Span::styled(" | ", Style::default().fg(colors.border())), Span::styled(right_fit, Style::default().fg(colors.fg())), ])); } let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); frame.render_widget(paragraph, inner); }