Files
keydr/src/main.rs

3188 lines
110 KiB
Rust

mod app;
mod config;
mod engine;
mod event;
mod generator;
mod keyboard;
mod session;
mod store;
mod ui;
use std::io;
use std::time::{Duration, Instant};
use anyhow::Result;
use clap::Parser;
use crossterm::event::{
KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, KeyboardEnhancementFlags,
ModifierKeyCode, 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, Paragraph, Widget, Wrap};
use app::{App, AppScreen, DrillMode, MilestoneKind, StatusKind};
use engine::skill_tree::{DrillScope, find_key_branch};
use keyboard::display::key_display_name;
use keyboard::finger::Hand;
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 ui::components::dashboard::Dashboard;
use ui::components::keyboard_diagram::KeyboardDiagram;
use ui::components::skill_tree::{SkillTreeWidget, detail_line_count, selectable_branches};
use ui::components::stats_dashboard::StatsDashboard;
use ui::components::stats_sidebar::StatsSidebar;
use ui::components::typing_area::TypingArea;
use ui::layout::AppLayout;
#[derive(Parser)]
#[command(
name = "keydr",
version,
about = "Terminal typing tutor with adaptive learning"
)]
struct Cli {
#[arg(short, long, help = "Theme name")]
theme: Option<String>,
#[arg(short, long, help = "Keyboard layout (qwerty, dvorak, colemak)")]
layout: Option<String>,
#[arg(short, long, help = "Number of words per drill")]
words: Option<usize>,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let mut app = App::new();
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;
}
}
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
// Try to enable keyboard enhancement for Release event support
let keyboard_enhanced = execute!(
io::stdout(),
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| KeyboardEnhancementFlags::DISAMBIGUATE_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(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
if let Err(err) = result {
eprintln!("Error: {err:?}");
}
Ok(())
}
fn run_app(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
events: &EventHandler,
) -> Result<()> {
loop {
terminal.draw(|frame| render(frame, app))?;
match events.next()? {
AppEvent::Key(key) => handle_key(app, key),
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 after 150ms if no Release event received
if let Some(last) = app.last_key_time {
if last.elapsed() > Duration::from_millis(150) && !app.depressed_keys.is_empty()
{
app.depressed_keys.clear();
app.last_key_time = None;
}
// Clear shift_held after 200ms as fallback
if last.elapsed() > Duration::from_millis(200) && app.shift_held {
app.shift_held = false;
}
}
}
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).
// This only works in terminals with native Kitty keyboard protocol support
// (Kitty, WezTerm, foot, Ghostty). In tmux/mosh/SSH, the protocol is stripped
// and crossterm infers SHIFT from character case, making it impossible to
// distinguish Shift+a from CapsLock+a.
app.caps_lock = key.state.contains(KeyEventState::CAPS_LOCK);
// Track depressed keys and shift state for keyboard diagram
match (&key.code, key.kind) {
(KeyCode::Modifier(ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift), KeyEventKind::Press) => {
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) => {
app.depressed_keys.insert(ch.to_ascii_lowercase());
app.last_key_time = Some(Instant::now());
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
}
(KeyCode::Char(ch), KeyEventKind::Release) => {
app.depressed_keys.remove(&ch.to_ascii_lowercase());
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;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
app.should_quit = true;
return;
}
// Milestone overlays are modal: any key dismisses exactly one popup and is consumed.
if !app.milestone_queue.is_empty() {
app.milestone_queue.pop_front();
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::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),
}
}
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.drill_mode = DrillMode::Adaptive;
app.drill_scope = DrillScope::Global;
app.start_drill();
}
KeyCode::Char('2') => {
if app.config.code_onboarding_done {
app.go_to_code_language_select();
} else {
app.go_to_code_intro();
}
}
KeyCode::Char('3') => {
if app.config.passage_onboarding_done {
app.go_to_passage_book_select();
} else {
app.go_to_passage_intro();
}
}
KeyCode::Char('t') => app.go_to_skill_tree(),
KeyCode::Char('b') => app.go_to_keyboard(),
KeyCode::Char('s') => app.go_to_stats(),
KeyCode::Char('c') => app.go_to_settings(),
KeyCode::Up | KeyCode::Char('k') => app.menu.prev(),
KeyCode::Down | KeyCode::Char('j') => app.menu.next(),
KeyCode::Enter => match app.menu.selected {
0 => {
app.drill_mode = DrillMode::Adaptive;
app.drill_scope = DrillScope::Global;
app.start_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_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;
}
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 = 5;
// 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 {
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_visible = app.drill_history.len().min(20) - 1;
app.history_selected = (app.history_selected + 1).min(max_visible);
}
}
KeyCode::Char('k') | KeyCode::Up => {
app.history_selected = app.history_selected.saturating_sub(1);
}
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::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::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 handle_settings_key(app: &mut App, key: KeyEvent) {
const MAX_SETTINGS: usize = 15;
// 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();
}
KeyCode::Char('n') | KeyCode::Esc => {
app.settings_confirm_import = false;
}
_ => {}
}
return;
}
// Priority 4: editing a path field
if app.settings_editing_download_dir || app.settings_editing_export_path || app.settings_editing_import_path {
match key.code {
KeyCode::Esc => {
app.clear_settings_modals();
}
KeyCode::Backspace => {
if app.settings_editing_download_dir {
if app.settings_selected == 5 {
app.config.code_download_dir.pop();
} else if app.settings_selected == 9 {
app.config.passage_download_dir.pop();
}
} else if app.settings_editing_export_path {
app.settings_export_path.pop();
} else if app.settings_editing_import_path {
app.settings_import_path.pop();
}
}
KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
if app.settings_editing_download_dir {
if app.settings_selected == 5 {
app.config.code_download_dir.push(ch);
} else if app.settings_selected == 9 {
app.config.passage_download_dir.push(ch);
}
} else if app.settings_editing_export_path {
app.settings_export_path.push(ch);
} else if app.settings_editing_import_path {
app.settings_import_path.push(ch);
}
}
_ => {}
}
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 => {
match app.settings_selected {
5 | 9 => {
app.clear_settings_modals();
app.settings_editing_download_dir = true;
}
7 => app.start_code_downloads_from_settings(),
11 => app.start_passage_downloads_from_settings(),
12 => {
app.clear_settings_modals();
app.settings_editing_export_path = true;
}
13 => app.export_data(),
14 => {
app.clear_settings_modals();
app.settings_editing_import_path = true;
}
15 => {
app.clear_settings_modals();
app.settings_confirm_import = true;
}
_ => app.settings_cycle_forward(),
}
}
KeyCode::Right | KeyCode::Char('l') => {
match app.settings_selected {
5 | 7 | 9 | 11 | 12 | 13 | 14 | 15 => {} // path/button fields
_ => app.settings_cycle_forward(),
}
}
KeyCode::Left | KeyCode::Char('h') => {
match app.settings_selected {
5 | 7 | 9 | 11 | 12 | 13 | 14 | 15 => {} // path/button fields
_ => app.settings_cycle_backward(),
}
}
_ => {}
}
}
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_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_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 handle_passage_download_progress_key(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => 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 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_skill_tree_key(app: &mut App, key: KeyEvent) {
const DETAIL_SCROLL_STEP: usize = 10;
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
|| status == engine::skill_tree::BranchStatus::InProgress
{
app.start_branch_drill(branch_id);
}
}
}
_ => {}
}
}
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 branch_list_height = branches.len() as u16 * 2 + 1;
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(branch_list_height.min(inner.height.saturating_sub(6))),
Constraint::Length(1),
Constraint::Min(4),
Constraint::Length(2),
])
.split(inner);
let detail_height = layout.get(2).map(|r| r.height as usize).unwrap_or(0);
let selected = app
.skill_tree_selected
.min(branches.len().saturating_sub(1));
let total_lines = detail_line_count(branches[selected]);
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);
// 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::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),
}
}
fn render_menu(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),
Constraint::Min(0),
Constraint::Length(1),
])
.split(area);
let streak_text = if app.profile.streak_days > 0 {
format!(" | {} day streak", app.profile.streak_days)
} 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 = format!(
" Key Progress {unlocked}/{total_keys} ({mastered} mastered) | Target {} WPM{}",
app.config.target_wpm, 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,
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 = Paragraph::new(Line::from(vec![Span::styled(
" [1-3] Start [t] Skill Tree [b] Keyboard [s] Stats [c] Settings [q] Quit ",
Style::default().fg(colors.text_pending()),
)]));
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 = match app.drill_mode {
DrillMode::Adaptive => "Adaptive",
DrillMode::Code => "Code (Unranked)",
DrillMode::Passage => "Passage (Unranked)",
};
// 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 header_text =
format!(" {mode_name} | WPM: {wpm:.0} | Acc: {accuracy:.1}% | Errors: {errors}");
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} Drill ");
let focus_text = if app.drill_mode == DrillMode::Adaptive {
let focused = app
.skill_tree
.focused_key(app.drill_scope, &app.ranked_key_stats);
if let Some(focused) = focused {
format!(" | Focus: '{focused}'")
} else {
String::new()
}
} else {
String::new()
};
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> =
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 progress_height = if show_progress && area.height >= 25 {
(active_branches.len().min(6) as u16 + 1).max(2) // +1 for overall line
} else if show_progress && area.height >= 20 {
2 // active branch + overall
} else if show_progress {
1 // active branch only
} else {
0
};
let kbd_height = if show_kbd {
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 mut constraints: Vec<Constraint> = 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 = if app.drill_mode == DrillMode::Code {
" Code source "
} else {
" Passage source "
};
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 footer = Paragraph::new(Line::from(Span::styled(
" [ESC] End drill [Backspace] Delete ",
Style::default().fg(colors.text_pending()),
)));
frame.render_widget(footer, app_layout.footer);
}
}
fn render_milestone_overlay(
frame: &mut ratatui::Frame,
app: &App,
milestone: &app::KeyMilestonePopup,
) {
let area = frame.area();
let colors = &app.theme.colors;
// Determine overlay size based on terminal height:
// Large (>=25): full keyboard diagram
// Medium (>=15): compact keyboard diagram
// Small (<15): text only
let kbd_mode = overlay_keyboard_mode(area.height);
let overlay_height = 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 = match milestone.kind {
MilestoneKind::Unlock => " Key Unlocked! ",
MilestoneKind::Mastery => " Key Mastered! ",
};
let block = Block::bordered()
.title(title)
.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<Line> = Vec::new();
// Key display line
let key_action = match milestone.kind {
MilestoneKind::Unlock => "unlocked",
MilestoneKind::Mastery => "mastered",
};
let key_names: Vec<String> = 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()
}
};
lines.push(Line::from(Span::styled(
format!(" {key_label}: Use your {finger_desc}"),
Style::default().fg(colors.fg()),
)));
// Shift key guidance for shifted characters
let fa = app.keyboard_model.finger_for_char(*ch);
if ch.is_ascii_uppercase()
|| (!ch.is_ascii_lowercase()
&& !ch.is_ascii_digit()
&& !ch.is_ascii_whitespace()
&& *ch != ' ')
{
let shift_hint = if fa.hand == keyboard::finger::Hand::Left {
"Hold Right Shift (right pinky)"
} else {
"Hold Left Shift (left pinky)"
};
lines.push(Line::from(Span::styled(
format!(" {shift_hint}"),
Style::default().fg(colors.text_pending()),
)));
}
}
}
// Encouraging message (randomly selected at creation time)
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {}", milestone.message),
Style::default().fg(colors.focused_key()),
)));
// Keyboard diagram (if space permits)
if kbd_mode > 0 {
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_ascii_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 = Paragraph::new(Line::from(Span::styled(
" Press any key to continue",
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::session::result::DrillResult;
use chrono::{TimeDelta, Utc};
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 milestone_overlay_blocks_underlying_input() {
let mut app = App::new();
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",
});
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 = App::new();
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",
});
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",
});
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 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 = App::new();
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 = App::new();
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);
}
#[test]
fn result_delete_confirmation_cancel_keeps_history() {
let mut app = App::new();
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);
}
#[test]
fn result_continue_shortcuts_start_next_drill() {
let mut app = App::new();
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 = App::new();
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 {
[
app.settings_confirm_import,
app.settings_export_conflict,
app.settings_editing_export_path,
app.settings_editing_import_path,
app.settings_editing_download_dir,
]
.iter()
.filter(|&&f| f)
.count()
}
#[test]
fn settings_modal_invariant_enter_export_path_clears_others() {
let mut app = App::new();
app.screen = AppScreen::Settings;
// First, activate import confirmation
app.settings_selected = 15; // Import Data
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 = 12; // Export Path
handle_settings_key(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(app.settings_editing_export_path);
assert!(modal_edit_count(&app) <= 1);
// Esc out
handle_settings_key(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
assert!(!app.settings_editing_export_path);
}
#[test]
fn settings_modal_invariant_enter_import_path_clears_others() {
let mut app = App::new();
app.screen = AppScreen::Settings;
// Activate export path editing first
app.settings_selected = 12;
handle_settings_key(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(app.settings_editing_export_path);
// Esc out, then enter import path editing
handle_settings_key(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
app.settings_selected = 14; // Import Path
handle_settings_key(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(app.settings_editing_import_path);
assert!(!app.settings_editing_export_path);
assert!(modal_edit_count(&app) <= 1);
}
#[test]
fn settings_confirm_import_dialog_y_n_esc() {
let mut app = App::new();
app.screen = AppScreen::Settings;
// Trigger import confirmation
app.settings_selected = 15;
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 = 15;
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 = App::new();
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"));
}
}
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);
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 = format!("Delete session #{idx}? (y/n)");
frame.render_widget(ratatui::widgets::Clear, dialog_area);
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 ")
.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 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_confirm_delete,
&app.keyboard_model,
);
frame.render_widget(dashboard, area);
}
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 block = Block::bordered()
.title(" Settings ")
.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: Vec<(String, String, bool)> = vec![
(
"Target WPM".to_string(),
format!("{}", app.config.target_wpm),
false,
),
("Theme".to_string(), app.config.theme.clone(), false),
(
"Word Count".to_string(),
format!("{}", app.config.word_count),
false,
),
(
"Code Language".to_string(),
app.config.code_language.clone(),
false,
),
(
"Code Downloads".to_string(),
if app.config.code_downloads_enabled {
"On".to_string()
} else {
"Off".to_string()
},
false,
),
(
"Code Download Dir".to_string(),
app.config.code_download_dir.clone(),
true, // path field
),
(
"Snippets per Repo".to_string(),
if app.config.code_snippets_per_repo == 0 {
"Unlimited".to_string()
} else {
format!("{}", app.config.code_snippets_per_repo)
},
false,
),
(
"Download Code Now".to_string(),
"Run downloader".to_string(),
false,
),
(
"Passage Downloads".to_string(),
if app.config.passage_downloads_enabled {
"On".to_string()
} else {
"Off".to_string()
},
false,
),
(
"Passage Download Dir".to_string(),
app.config.passage_download_dir.clone(),
true, // path field
),
(
"Paragraphs per Book".to_string(),
if app.config.passage_paragraphs_per_book == 0 {
"Whole book".to_string()
} else {
format!("{}", app.config.passage_paragraphs_per_book)
},
false,
),
(
"Download Passages Now".to_string(),
"Run downloader".to_string(),
false,
),
(
"Export Path".to_string(),
app.settings_export_path.clone(),
true, // path field
),
(
"Export Data".to_string(),
"Export now".to_string(),
false,
),
(
"Import Path".to_string(),
app.settings_import_path.clone(),
true, // path field
),
(
"Import Data".to_string(),
"Import now".to_string(),
false,
),
];
let header_height = if inner.height > 0 { 1 } else { 0 };
let footer_height = if inner.height > header_height { 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 header = Paragraph::new(Line::from(Span::styled(
" Use arrows to navigate, Enter/Right to change, ESC to save & exit",
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::<Vec<_>>(),
)
.split(layout[1]);
for (row, (label, value, is_path)) 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 = i == 7 || i == 11 || i == 13 || i == 15;
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 && *is_path && (
app.settings_editing_download_dir
|| app.settings_editing_export_path
|| app.settings_editing_import_path
);
let lines = if *is_path {
let path_line = if is_editing_this_path {
format!(" {value}_")
} else {
format!(" {value}")
};
vec![
Line::from(Span::styled(
if is_editing_this_path {
format!("{indicator}{label}: (editing)")
} else {
label_text
},
label_style,
)),
Line::from(Span::styled(path_line, 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 any_path_editing = app.settings_editing_download_dir
|| app.settings_editing_export_path
|| app.settings_editing_import_path;
let footer_hints: Vec<&str> = if any_path_editing {
vec!["Editing path:", "[Type/Backspace] Modify", "[ESC] Done editing"]
} else {
vec![
"[ESC] Save & back",
"[Enter/arrows] Change value",
"[Enter on path] Edit",
]
};
let footer_lines: Vec<Line> = pack_hint_lines(&footer_hints, layout[2].width as usize)
.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 = match msg.kind {
StatusKind::Success => " Success ",
StatusKind::Error => " Error ",
};
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(
" 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 dialog = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
" A file already exists at this path.",
Style::default().fg(colors.fg()),
)),
Line::from(""),
Line::from(Span::styled(
" [d] Overwrite [r] Rename [Esc] Cancel",
Style::default().fg(colors.text_pending()),
)),
])
.style(Style::default().bg(colors.bg()))
.block(
Block::bordered()
.title(" File Exists ")
.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 dialog = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
" This will erase your current data.",
Style::default().fg(colors.fg()),
)),
Line::from(Span::styled(
" Export first if you want to keep it.",
Style::default().fg(colors.text_pending()),
)),
Line::from(""),
Line::from(Span::styled(
" Proceed? (y/n)",
Style::default().fg(colors.fg()),
)),
])
.style(Style::default().bg(colors.bg()))
.block(
Block::bordered()
.title(" Confirm Import ")
.border_style(Style::default().fg(colors.error()))
.style(Style::default().bg(colors.bg())),
);
frame.render_widget(dialog, dialog_area);
}
}
fn wrapped_line_count(text: &str, width: usize) -> usize {
if width == 0 {
return 0;
}
let chars = text.chars().count().max(1);
chars.div_ceil(width)
}
fn pack_hint_lines(hints: &[&str], width: usize) -> Vec<String> {
if width == 0 || hints.is_empty() {
return Vec::new();
}
let prefix = " ";
let separator = " ";
let mut out: Vec<String> = Vec::new();
let mut current = prefix.to_string();
let mut has_hint = false;
for hint in hints {
if hint.is_empty() {
continue;
}
let candidate = if has_hint {
format!("{current}{separator}{hint}")
} else {
format!("{current}{hint}")
};
if candidate.chars().count() <= width {
current = candidate;
has_hint = true;
} else {
if has_hint {
out.push(current);
}
current = format!("{prefix}{hint}");
has_hint = true;
}
}
if has_hint {
out.push(current);
}
out
}
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 block = Block::bordered()
.title(" Select Code Language ")
.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 footer_hints = [
"[Up/Down/PgUp/PgDn] Navigate",
"[Enter] Confirm",
"[ESC] Back",
];
let disabled_notice =
" Some languages are disabled: enable network downloads in intro/settings.";
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<Line> = Vec::new();
// Show scroll indicator at top if scrolled down
if scroll > 0 {
lines.push(Line::from(Span::styled(
format!(" ... {} more above ...", scroll),
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 = if is_current { " (current)" } else { "" };
// Determine availability label
let availability = if *key == "all" {
String::new()
} else if let Some(lang) = language_by_key(key) {
if lang.has_builtin {
" (built-in)".to_string()
} else if is_language_cached(cache_dir, key) {
" (cached)".to_string()
} else if is_disabled {
" (disabled: download required)".to_string()
} else {
" (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() {
lines.push(Line::from(Span::styled(
format!(" ... {} more below ...", options.len() - visible_end),
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<Line> = 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 block = Block::bordered()
.title(" Select Passage Source ")
.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 footer_hints = ["[Up/Down] Navigate", "[Enter] Confirm", "[ESC] Back"];
let disabled_notice =
" Some sources are disabled: enable network downloads in intro/settings.";
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<Line> = 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" {
" (built-in)".to_string()
} else if is_book_cached(&app.config.passage_download_dir, key) {
" (cached)".to_string()
} else if is_disabled {
" (disabled: download required)".to_string()
} else {
" (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<Line> = 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 block = Block::bordered()
.title(" Passage Downloads Setup ")
.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 {
"whole book".to_string()
} else {
app.passage_intro_paragraph_limit.to_string()
};
let fields = vec![
(
"Enable network downloads",
if app.passage_intro_downloads_enabled {
"On".to_string()
} else {
"Off".to_string()
},
),
("Download directory", app.passage_intro_download_dir.clone()),
("Paragraphs per book (0 = whole)", paragraphs_value),
("Start passage drill", "Confirm".to_string()),
];
let mut lines = vec![
Line::from(Span::styled(
"Configure passage source settings before your first passage drill.",
Style::default()
.fg(colors.fg())
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"Downloads are lazy: books are fetched only when first needed.",
Style::default().fg(colors.text_pending()),
)),
Line::from(Span::styled(
"If you exit without confirming, this dialog will appear again next time.",
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!(" Downloading current book: [{bar}] {done_bytes}/{total_bytes} bytes")
} else {
format!(" Downloading current book: {done_bytes} 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() {
lines.push(Line::from(Span::styled(
format!(
" Current: {} (book {}/{})",
app.passage_intro_current_book,
done_books.saturating_add(1).min(total_books),
total_books
),
Style::default().fg(colors.text_pending()),
)));
}
}
let hint_lines = if app.passage_intro_downloading {
Vec::new()
} else {
pack_hint_lines(
&[
"[Up/Down] Navigate",
"[Left/Right] Adjust",
"[Type/Backspace] Edit",
"[Enter] Confirm",
"[ESC] Cancel",
],
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 block = Block::bordered()
.title(" Downloading Passage Source ")
.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() {
"Preparing download...".to_string()
} else {
app.passage_intro_current_book.clone()
};
let lines = vec![
Line::from(Span::styled(
format!(" Book: {book_name}"),
Style::default()
.fg(colors.fg())
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
if total_bytes > 0 {
format!(" [{bar}] {done_bytes}/{total_bytes} bytes")
} else {
format!(" Downloaded: {done_bytes} bytes")
},
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 block = Block::bordered()
.title(" Code Downloads Setup ")
.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 {
"unlimited".to_string()
} else {
app.code_intro_snippets_per_repo.to_string()
};
let fields = vec![
(
"Enable network downloads",
if app.code_intro_downloads_enabled {
"On".to_string()
} else {
"Off".to_string()
},
),
("Download directory", app.code_intro_download_dir.clone()),
("Snippets per repo (0 = unlimited)", snippets_value),
("Start code drill", "Confirm".to_string()),
];
let mut lines = vec![
Line::from(Span::styled(
"Configure code source settings before your first code drill.",
Style::default()
.fg(colors.fg())
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"Downloads are lazy: code is fetched only when first needed.",
Style::default().fg(colors.text_pending()),
)),
Line::from(Span::styled(
"If you exit without confirming, this dialog will appear again next time.",
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!(" Downloading: [{bar}] {done_bytes}/{total_bytes} bytes")
} else {
format!(" Downloading: {done_bytes} 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() {
lines.push(Line::from(Span::styled(
format!(
" Current: {} (repo {}/{})",
app.code_intro_current_repo,
done_repos.saturating_add(1).min(total_repos),
total_repos
),
Style::default().fg(colors.text_pending()),
)));
}
}
let hint_lines = if app.code_intro_downloading {
Vec::new()
} else {
pack_hint_lines(
&[
"[Up/Down] Navigate",
"[Left/Right] Adjust",
"[Type/Backspace] Edit",
"[Enter] Confirm",
"[ESC] Cancel",
],
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 block = Block::bordered()
.title(" Downloading Code Source ")
.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() {
"Preparing download...".to_string()
} else {
app.code_intro_current_repo.clone()
};
let lines = vec![
Line::from(Span::styled(
format!(" Repo: {repo_name}"),
Style::default()
.fg(colors.fg())
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
if total_bytes > 0 {
format!(" [{bar}] {done_bytes}/{total_bytes} bytes")
} else {
format!(" Downloaded: {done_bytes} bytes")
},
Style::default().fg(colors.accent()),
)),
Line::from(""),
Line::from(Span::styled(
" [ESC] Cancel",
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 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);
}
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) => {
app.keyboard_explorer_selected = Some(ch);
app.key_accuracy(ch, false);
app.key_accuracy(ch, true);
}
KeyCode::Tab => {
app.keyboard_explorer_selected = Some('\t');
app.key_accuracy('\t', false);
app.key_accuracy('\t', true);
}
KeyCode::Enter => {
app.keyboard_explorer_selected = Some('\n');
app.key_accuracy('\n', false);
app.key_accuracy('\n', true);
}
KeyCode::Backspace => {
app.keyboard_explorer_selected = Some('\x08');
app.key_accuracy('\x08', false);
app.key_accuracy('\x08', true);
}
_ => {}
}
}
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 header_lines = vec![
Line::from(""),
Line::from(Span::styled(
" Keyboard Explorer ",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"Press any key to see details",
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 footer = Paragraph::new(Line::from(vec![Span::styled(
" [ESC] Back ",
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 hint = Paragraph::new(Line::from(Span::styled(
"Press a key to see its details",
Style::default().fg(colors.text_pending()),
)))
.alignment(ratatui::layout::Alignment::Center)
.block(
Block::bordered()
.border_style(Style::default().fg(colors.border()))
.title(" Key Details "),
);
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() {
format!(" Key Details: '{}' ", selected)
} else {
format!(" Key Details: {} ", display_name)
};
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()
|| matches!(
selected,
'!' | '@' | '#' | '$' | '%' | '^' | '&' | '*' | '(' | ')' | '_' | '+'
| '{' | '}' | '|' | ':' | '"' | '<' | '>' | '?' | '~'
);
let shift_guidance = if is_shifted {
if finger.hand == Hand::Left {
"Hold Right Shift (right pinky)".to_string()
} else {
"Hold Left Shift (left pinky)".to_string()
}
} else {
"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);
}
}
"No data".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");
}
}
"No data".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);
}
}
"No data".to_string()
};
let (branch_name, level_name) = if let Some((branch, level, pos)) = find_key_branch(selected) {
(branch.name.to_string(), format!("{level} (key #{pos})"))
} else {
("Unknown".to_string(), "Unknown".to_string())
};
// 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<String> = vec![
format!("Finger: {}", finger.description()),
format!("Shift: {shift_guidance}"),
format!("Overall Avg Time: {}", fmt_avg_time(overall_stat)),
format!("Overall Best Time: {}", fmt_best_time(overall_stat)),
format!("Overall Samples: {}", fmt_samples(overall_stat)),
format!("Overall Accuracy: {}", fmt_acc(overall_acc)),
];
let mut right_col: Vec<String> = vec![
format!("Branch: {branch_name}"),
format!("Level: {level_name}"),
format!("Unlocked: {}", if is_unlocked { "Yes" } else { "No" }),
format!("In Focus?: {}", if in_focus { "Yes" } else { "No" }),
];
if is_unlocked {
right_col.push(format!("Mastery: {mastery_text}"));
} else {
right_col.push("Mastery: Locked".to_string());
}
right_col.push(format!("Ranked Avg Time: {}", fmt_avg_time(ranked_stat)));
right_col.push(format!("Ranked Best Time: {}", fmt_best_time(ranked_stat)));
right_col.push(format!("Ranked Samples: {}", fmt_samples(ranked_stat)));
right_col.push(format!("Ranked Accuracy: {}", fmt_acc(ranked_acc)));
if left_col.is_empty() {
left_col.push("No data yet".to_string());
}
if right_col.is_empty() {
right_col.push("No data yet".to_string());
}
let mut lines: Vec<Line> = 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);
}