Files
keydr/src/main.rs

7523 lines
282 KiB
Rust

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<String>,
#[arg(
short,
long,
help = "Keyboard layout (qwerty, dvorak, colemak, de_qwertz, fr_azerty)"
)]
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();
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<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::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<String> {
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<char> = 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<String> {
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<usize> {
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::<Vec<_>>(),
)
.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<String>) =
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<usize> {
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<String>) =
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<String>) =
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<Line> = 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> =
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<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_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<Line> = 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<Line> = 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<Line> = 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<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()
}
};
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<String> = 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<AnomalyBigramRow> = 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<AnomalyBigramRow> = 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::<Vec<_>>(),
)
.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<Line> = 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<Line> = 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<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(
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<Line> = 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<Line> = 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<Line> = 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<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(
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<Line> = 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<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 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<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" {
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<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 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<String> = 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<String> = 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<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);
}