3188 lines
110 KiB
Rust
3188 lines
110 KiB
Rust
mod app;
|
|
mod config;
|
|
mod engine;
|
|
mod event;
|
|
mod generator;
|
|
mod keyboard;
|
|
mod session;
|
|
mod store;
|
|
mod ui;
|
|
|
|
use std::io;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use anyhow::Result;
|
|
use clap::Parser;
|
|
use crossterm::event::{
|
|
KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, KeyboardEnhancementFlags,
|
|
ModifierKeyCode, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
|
};
|
|
use crossterm::execute;
|
|
use crossterm::terminal::{
|
|
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
|
};
|
|
use ratatui::Terminal;
|
|
use ratatui::backend::CrosstermBackend;
|
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
|
use ratatui::style::{Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
|
|
|
|
use app::{App, AppScreen, DrillMode, MilestoneKind, StatusKind};
|
|
use engine::skill_tree::{DrillScope, find_key_branch};
|
|
use keyboard::display::key_display_name;
|
|
use keyboard::finger::Hand;
|
|
use event::{AppEvent, EventHandler};
|
|
use generator::code_syntax::{code_language_options, is_language_cached, language_by_key};
|
|
use generator::passage::{is_book_cached, passage_options};
|
|
use ui::components::dashboard::Dashboard;
|
|
use ui::components::keyboard_diagram::KeyboardDiagram;
|
|
use ui::components::skill_tree::{SkillTreeWidget, detail_line_count, selectable_branches};
|
|
use ui::components::stats_dashboard::StatsDashboard;
|
|
use ui::components::stats_sidebar::StatsSidebar;
|
|
use ui::components::typing_area::TypingArea;
|
|
use ui::layout::AppLayout;
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "keydr",
|
|
version,
|
|
about = "Terminal typing tutor with adaptive learning"
|
|
)]
|
|
struct Cli {
|
|
#[arg(short, long, help = "Theme name")]
|
|
theme: Option<String>,
|
|
|
|
#[arg(short, long, help = "Keyboard layout (qwerty, dvorak, colemak)")]
|
|
layout: Option<String>,
|
|
|
|
#[arg(short, long, help = "Number of words per drill")]
|
|
words: Option<usize>,
|
|
}
|
|
|
|
fn main() -> Result<()> {
|
|
let cli = Cli::parse();
|
|
|
|
let mut app = App::new();
|
|
|
|
if let Some(words) = cli.words {
|
|
app.config.word_count = words;
|
|
}
|
|
if let Some(theme_name) = cli.theme {
|
|
if let Some(theme) = ui::theme::Theme::load(&theme_name) {
|
|
let theme: &'static ui::theme::Theme = Box::leak(Box::new(theme));
|
|
app.theme = theme;
|
|
app.menu.theme = theme;
|
|
}
|
|
}
|
|
|
|
enable_raw_mode()?;
|
|
let mut stdout = io::stdout();
|
|
execute!(stdout, EnterAlternateScreen)?;
|
|
|
|
// Try to enable keyboard enhancement for Release event support
|
|
let keyboard_enhanced = execute!(
|
|
io::stdout(),
|
|
PushKeyboardEnhancementFlags(
|
|
KeyboardEnhancementFlags::REPORT_EVENT_TYPES
|
|
| KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES,
|
|
)
|
|
)
|
|
.is_ok();
|
|
|
|
let backend = CrosstermBackend::new(stdout);
|
|
let mut terminal = Terminal::new(backend)?;
|
|
terminal.hide_cursor()?;
|
|
|
|
let events = EventHandler::new(Duration::from_millis(100));
|
|
|
|
let result = run_app(&mut terminal, &mut app, &events);
|
|
|
|
if keyboard_enhanced {
|
|
let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags);
|
|
}
|
|
disable_raw_mode()?;
|
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
|
terminal.show_cursor()?;
|
|
|
|
if let Err(err) = result {
|
|
eprintln!("Error: {err:?}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn run_app(
|
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
app: &mut App,
|
|
events: &EventHandler,
|
|
) -> Result<()> {
|
|
loop {
|
|
terminal.draw(|frame| render(frame, app))?;
|
|
|
|
match events.next()? {
|
|
AppEvent::Key(key) => handle_key(app, key),
|
|
AppEvent::Tick => {
|
|
if (app.screen == AppScreen::PassageIntro
|
|
|| app.screen == AppScreen::PassageDownloadProgress)
|
|
&& app.passage_intro_downloading
|
|
{
|
|
app.process_passage_download_tick();
|
|
}
|
|
if (app.screen == AppScreen::CodeIntro
|
|
|| app.screen == AppScreen::CodeDownloadProgress)
|
|
&& app.code_intro_downloading
|
|
{
|
|
app.process_code_download_tick();
|
|
}
|
|
// Fallback: clear depressed keys after 150ms if no Release event received
|
|
if let Some(last) = app.last_key_time {
|
|
if last.elapsed() > Duration::from_millis(150) && !app.depressed_keys.is_empty()
|
|
{
|
|
app.depressed_keys.clear();
|
|
app.last_key_time = None;
|
|
}
|
|
// Clear shift_held after 200ms as fallback
|
|
if last.elapsed() > Duration::from_millis(200) && app.shift_held {
|
|
app.shift_held = false;
|
|
}
|
|
}
|
|
}
|
|
AppEvent::Resize(_, _) => {}
|
|
}
|
|
|
|
if app.should_quit {
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_key(app: &mut App, key: KeyEvent) {
|
|
// Track caps lock state via Kitty protocol metadata (KeyEventState::CAPS_LOCK).
|
|
// This only works in terminals with native Kitty keyboard protocol support
|
|
// (Kitty, WezTerm, foot, Ghostty). In tmux/mosh/SSH, the protocol is stripped
|
|
// and crossterm infers SHIFT from character case, making it impossible to
|
|
// distinguish Shift+a from CapsLock+a.
|
|
app.caps_lock = key.state.contains(KeyEventState::CAPS_LOCK);
|
|
|
|
// Track depressed keys and shift state for keyboard diagram
|
|
match (&key.code, key.kind) {
|
|
(KeyCode::Modifier(ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift), KeyEventKind::Press) => {
|
|
app.shift_held = true;
|
|
app.last_key_time = Some(Instant::now());
|
|
return; // Don't dispatch bare shift presses to screen handlers
|
|
}
|
|
(KeyCode::Modifier(ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift), KeyEventKind::Release) => {
|
|
app.shift_held = false;
|
|
return;
|
|
}
|
|
(KeyCode::Char(ch), KeyEventKind::Press) => {
|
|
app.depressed_keys.insert(ch.to_ascii_lowercase());
|
|
app.last_key_time = Some(Instant::now());
|
|
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
|
|
}
|
|
(KeyCode::Char(ch), KeyEventKind::Release) => {
|
|
app.depressed_keys.remove(&ch.to_ascii_lowercase());
|
|
return; // Don't process Release events as input
|
|
}
|
|
(KeyCode::Backspace, KeyEventKind::Press) => {
|
|
app.depressed_keys.insert('\x08');
|
|
app.last_key_time = Some(Instant::now());
|
|
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
|
|
}
|
|
(KeyCode::Backspace, KeyEventKind::Release) => {
|
|
app.depressed_keys.remove(&'\x08');
|
|
return;
|
|
}
|
|
(KeyCode::Tab, KeyEventKind::Press) => {
|
|
app.depressed_keys.insert('\t');
|
|
app.last_key_time = Some(Instant::now());
|
|
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
|
|
}
|
|
(KeyCode::Tab, KeyEventKind::Release) => {
|
|
app.depressed_keys.remove(&'\t');
|
|
return;
|
|
}
|
|
(KeyCode::Enter, KeyEventKind::Press) => {
|
|
app.depressed_keys.insert('\n');
|
|
app.last_key_time = Some(Instant::now());
|
|
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
|
|
}
|
|
(KeyCode::Enter, KeyEventKind::Release) => {
|
|
app.depressed_keys.remove(&'\n');
|
|
return;
|
|
}
|
|
(_, KeyEventKind::Release) => return,
|
|
_ => {
|
|
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
|
|
}
|
|
}
|
|
|
|
// Only process Press events — ignore Repeat to avoid inflating input
|
|
if key.kind != KeyEventKind::Press {
|
|
return;
|
|
}
|
|
|
|
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
|
app.should_quit = true;
|
|
return;
|
|
}
|
|
|
|
// Milestone overlays are modal: any key dismisses exactly one popup and is consumed.
|
|
if !app.milestone_queue.is_empty() {
|
|
app.milestone_queue.pop_front();
|
|
return;
|
|
}
|
|
|
|
match app.screen {
|
|
AppScreen::Menu => handle_menu_key(app, key),
|
|
AppScreen::Drill => handle_drill_key(app, key),
|
|
AppScreen::DrillResult => handle_result_key(app, key),
|
|
AppScreen::StatsDashboard => handle_stats_key(app, key),
|
|
AppScreen::Settings => handle_settings_key(app, key),
|
|
AppScreen::SkillTree => handle_skill_tree_key(app, key),
|
|
AppScreen::CodeLanguageSelect => handle_code_language_key(app, key),
|
|
AppScreen::PassageBookSelect => handle_passage_book_key(app, key),
|
|
AppScreen::PassageIntro => handle_passage_intro_key(app, key),
|
|
AppScreen::PassageDownloadProgress => handle_passage_download_progress_key(app, key),
|
|
AppScreen::CodeIntro => handle_code_intro_key(app, key),
|
|
AppScreen::CodeDownloadProgress => handle_code_download_progress_key(app, key),
|
|
AppScreen::Keyboard => handle_keyboard_explorer_key(app, key),
|
|
}
|
|
}
|
|
|
|
fn handle_menu_key(app: &mut App, key: KeyEvent) {
|
|
match key.code {
|
|
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
|
|
KeyCode::Char('1') => {
|
|
app.drill_mode = DrillMode::Adaptive;
|
|
app.drill_scope = DrillScope::Global;
|
|
app.start_drill();
|
|
}
|
|
KeyCode::Char('2') => {
|
|
if app.config.code_onboarding_done {
|
|
app.go_to_code_language_select();
|
|
} else {
|
|
app.go_to_code_intro();
|
|
}
|
|
}
|
|
KeyCode::Char('3') => {
|
|
if app.config.passage_onboarding_done {
|
|
app.go_to_passage_book_select();
|
|
} else {
|
|
app.go_to_passage_intro();
|
|
}
|
|
}
|
|
KeyCode::Char('t') => app.go_to_skill_tree(),
|
|
KeyCode::Char('b') => app.go_to_keyboard(),
|
|
KeyCode::Char('s') => app.go_to_stats(),
|
|
KeyCode::Char('c') => app.go_to_settings(),
|
|
KeyCode::Up | KeyCode::Char('k') => app.menu.prev(),
|
|
KeyCode::Down | KeyCode::Char('j') => app.menu.next(),
|
|
KeyCode::Enter => match app.menu.selected {
|
|
0 => {
|
|
app.drill_mode = DrillMode::Adaptive;
|
|
app.drill_scope = DrillScope::Global;
|
|
app.start_drill();
|
|
}
|
|
1 => {
|
|
if app.config.code_onboarding_done {
|
|
app.go_to_code_language_select();
|
|
} else {
|
|
app.go_to_code_intro();
|
|
}
|
|
}
|
|
2 => {
|
|
if app.config.passage_onboarding_done {
|
|
app.go_to_passage_book_select();
|
|
} else {
|
|
app.go_to_passage_intro();
|
|
}
|
|
}
|
|
3 => app.go_to_skill_tree(),
|
|
4 => app.go_to_keyboard(),
|
|
5 => app.go_to_stats(),
|
|
6 => app.go_to_settings(),
|
|
_ => {}
|
|
},
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_drill_key(app: &mut App, key: KeyEvent) {
|
|
// Route Enter/Tab as typed characters during active drills
|
|
if app.drill.is_some() {
|
|
match key.code {
|
|
KeyCode::Enter => {
|
|
app.type_char('\n');
|
|
return;
|
|
}
|
|
KeyCode::Tab => {
|
|
app.type_char('\t');
|
|
return;
|
|
}
|
|
KeyCode::BackTab => return, // Ignore Shift+Tab
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
let has_progress = app.drill.as_ref().is_some_and(|d| d.cursor > 0);
|
|
if has_progress {
|
|
app.finish_partial_drill();
|
|
} else {
|
|
app.go_to_menu();
|
|
}
|
|
}
|
|
KeyCode::Backspace => app.backspace(),
|
|
KeyCode::Char(ch) => app.type_char(ch),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_result_key(app: &mut App, key: KeyEvent) {
|
|
if app.history_confirm_delete {
|
|
match key.code {
|
|
KeyCode::Char('y') => {
|
|
app.delete_session();
|
|
app.history_confirm_delete = false;
|
|
}
|
|
KeyCode::Char('n') | KeyCode::Esc => {
|
|
app.history_confirm_delete = false;
|
|
}
|
|
_ => {}
|
|
}
|
|
return;
|
|
}
|
|
|
|
match key.code {
|
|
KeyCode::Char('c') | KeyCode::Enter | KeyCode::Char(' ') => app.continue_drill(),
|
|
KeyCode::Char('r') => app.retry_drill(),
|
|
KeyCode::Char('q') | KeyCode::Esc => app.go_to_menu(),
|
|
KeyCode::Char('s') => app.go_to_stats(),
|
|
KeyCode::Char('x') => {
|
|
if !app.drill_history.is_empty() {
|
|
// On result screen, delete always targets the just-completed (most recent) session.
|
|
app.history_selected = 0;
|
|
app.history_confirm_delete = true;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_stats_key(app: &mut App, key: KeyEvent) {
|
|
const STATS_TAB_COUNT: usize = 5;
|
|
|
|
// Confirmation dialog takes priority
|
|
if app.history_confirm_delete {
|
|
match key.code {
|
|
KeyCode::Char('y') => {
|
|
app.delete_session();
|
|
app.history_confirm_delete = false;
|
|
}
|
|
KeyCode::Char('n') | KeyCode::Esc => {
|
|
app.history_confirm_delete = false;
|
|
}
|
|
_ => {}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// History tab has row navigation
|
|
if app.stats_tab == 1 {
|
|
match key.code {
|
|
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
|
|
KeyCode::Char('j') | KeyCode::Down => {
|
|
if !app.drill_history.is_empty() {
|
|
let max_visible = app.drill_history.len().min(20) - 1;
|
|
app.history_selected = (app.history_selected + 1).min(max_visible);
|
|
}
|
|
}
|
|
KeyCode::Char('k') | KeyCode::Up => {
|
|
app.history_selected = app.history_selected.saturating_sub(1);
|
|
}
|
|
KeyCode::Char('x') | KeyCode::Delete => {
|
|
if !app.drill_history.is_empty() {
|
|
app.history_confirm_delete = true;
|
|
}
|
|
}
|
|
KeyCode::Char('1') => app.stats_tab = 0,
|
|
KeyCode::Char('2') => {} // already on history
|
|
KeyCode::Char('3') => app.stats_tab = 2,
|
|
KeyCode::Char('4') => app.stats_tab = 3,
|
|
KeyCode::Char('5') => app.stats_tab = 4,
|
|
KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % STATS_TAB_COUNT,
|
|
KeyCode::BackTab => {
|
|
app.stats_tab = if app.stats_tab == 0 {
|
|
STATS_TAB_COUNT - 1
|
|
} else {
|
|
app.stats_tab - 1
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
return;
|
|
}
|
|
|
|
match key.code {
|
|
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
|
|
KeyCode::Char('1') => app.stats_tab = 0,
|
|
KeyCode::Char('2') => app.stats_tab = 1,
|
|
KeyCode::Char('3') => app.stats_tab = 2,
|
|
KeyCode::Char('4') => app.stats_tab = 3,
|
|
KeyCode::Char('5') => app.stats_tab = 4,
|
|
KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % STATS_TAB_COUNT,
|
|
KeyCode::BackTab => {
|
|
app.stats_tab = if app.stats_tab == 0 {
|
|
STATS_TAB_COUNT - 1
|
|
} else {
|
|
app.stats_tab - 1
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_settings_key(app: &mut App, key: KeyEvent) {
|
|
const MAX_SETTINGS: usize = 15;
|
|
|
|
// Priority 1: dismiss status message
|
|
if app.settings_status_message.is_some() {
|
|
app.settings_status_message = None;
|
|
return;
|
|
}
|
|
|
|
// Priority 2: export conflict dialog
|
|
if app.settings_export_conflict {
|
|
match key.code {
|
|
KeyCode::Char('d') => {
|
|
app.settings_export_conflict = false;
|
|
app.export_data_overwrite();
|
|
}
|
|
KeyCode::Char('r') => {
|
|
app.settings_export_conflict = false;
|
|
app.export_data_rename();
|
|
}
|
|
KeyCode::Esc => {
|
|
app.settings_export_conflict = false;
|
|
}
|
|
_ => {}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Priority 3: import confirmation dialog
|
|
if app.settings_confirm_import {
|
|
match key.code {
|
|
KeyCode::Char('y') => {
|
|
app.settings_confirm_import = false;
|
|
app.import_data();
|
|
}
|
|
KeyCode::Char('n') | KeyCode::Esc => {
|
|
app.settings_confirm_import = false;
|
|
}
|
|
_ => {}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Priority 4: editing a path field
|
|
if app.settings_editing_download_dir || app.settings_editing_export_path || app.settings_editing_import_path {
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
app.clear_settings_modals();
|
|
}
|
|
KeyCode::Backspace => {
|
|
if app.settings_editing_download_dir {
|
|
if app.settings_selected == 5 {
|
|
app.config.code_download_dir.pop();
|
|
} else if app.settings_selected == 9 {
|
|
app.config.passage_download_dir.pop();
|
|
}
|
|
} else if app.settings_editing_export_path {
|
|
app.settings_export_path.pop();
|
|
} else if app.settings_editing_import_path {
|
|
app.settings_import_path.pop();
|
|
}
|
|
}
|
|
KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
if app.settings_editing_download_dir {
|
|
if app.settings_selected == 5 {
|
|
app.config.code_download_dir.push(ch);
|
|
} else if app.settings_selected == 9 {
|
|
app.config.passage_download_dir.push(ch);
|
|
}
|
|
} else if app.settings_editing_export_path {
|
|
app.settings_export_path.push(ch);
|
|
} else if app.settings_editing_import_path {
|
|
app.settings_import_path.push(ch);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
return;
|
|
}
|
|
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
let _ = app.config.save();
|
|
app.go_to_menu();
|
|
}
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
if app.settings_selected > 0 {
|
|
app.settings_selected -= 1;
|
|
}
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
if app.settings_selected < MAX_SETTINGS {
|
|
app.settings_selected += 1;
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
match app.settings_selected {
|
|
5 | 9 => {
|
|
app.clear_settings_modals();
|
|
app.settings_editing_download_dir = true;
|
|
}
|
|
7 => app.start_code_downloads_from_settings(),
|
|
11 => app.start_passage_downloads_from_settings(),
|
|
12 => {
|
|
app.clear_settings_modals();
|
|
app.settings_editing_export_path = true;
|
|
}
|
|
13 => app.export_data(),
|
|
14 => {
|
|
app.clear_settings_modals();
|
|
app.settings_editing_import_path = true;
|
|
}
|
|
15 => {
|
|
app.clear_settings_modals();
|
|
app.settings_confirm_import = true;
|
|
}
|
|
_ => app.settings_cycle_forward(),
|
|
}
|
|
}
|
|
KeyCode::Right | KeyCode::Char('l') => {
|
|
match app.settings_selected {
|
|
5 | 7 | 9 | 11 | 12 | 13 | 14 | 15 => {} // path/button fields
|
|
_ => app.settings_cycle_forward(),
|
|
}
|
|
}
|
|
KeyCode::Left | KeyCode::Char('h') => {
|
|
match app.settings_selected {
|
|
5 | 7 | 9 | 11 | 12 | 13 | 14 | 15 => {} // path/button fields
|
|
_ => app.settings_cycle_backward(),
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_code_language_key(app: &mut App, key: KeyEvent) {
|
|
let options = code_language_options();
|
|
let len = options.len();
|
|
if len == 0 {
|
|
return;
|
|
}
|
|
|
|
match key.code {
|
|
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
app.code_language_selected = app.code_language_selected.saturating_sub(1);
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
if app.code_language_selected + 1 < len {
|
|
app.code_language_selected += 1;
|
|
}
|
|
}
|
|
KeyCode::PageUp => {
|
|
app.code_language_selected = app.code_language_selected.saturating_sub(10);
|
|
}
|
|
KeyCode::PageDown => {
|
|
app.code_language_selected = (app.code_language_selected + 10).min(len - 1);
|
|
}
|
|
KeyCode::Home | KeyCode::Char('g') => {
|
|
app.code_language_selected = 0;
|
|
}
|
|
KeyCode::End | KeyCode::Char('G') => {
|
|
app.code_language_selected = len - 1;
|
|
}
|
|
KeyCode::Enter => {
|
|
if app.code_language_selected >= options.len() {
|
|
return;
|
|
}
|
|
let key = options[app.code_language_selected].0;
|
|
if !is_code_language_disabled(app, key) {
|
|
confirm_code_language_and_continue(app, &options);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
// Adjust scroll to keep selected item visible.
|
|
// Use a rough viewport estimate; render will use exact terminal size.
|
|
let viewport = 15usize;
|
|
if app.code_language_selected < app.code_language_scroll {
|
|
app.code_language_scroll = app.code_language_selected;
|
|
} else if app.code_language_selected >= app.code_language_scroll + viewport {
|
|
app.code_language_scroll = app.code_language_selected + 1 - viewport;
|
|
}
|
|
}
|
|
|
|
fn code_language_requires_download(app: &App, key: &str) -> bool {
|
|
if key == "all" {
|
|
return false;
|
|
}
|
|
let Some(lang) = language_by_key(key) else {
|
|
return false;
|
|
};
|
|
!lang.has_builtin && !is_language_cached(&app.config.code_download_dir, key)
|
|
}
|
|
|
|
fn is_code_language_disabled(app: &App, key: &str) -> bool {
|
|
!app.config.code_downloads_enabled && code_language_requires_download(app, key)
|
|
}
|
|
|
|
fn confirm_code_language_and_continue(app: &mut App, options: &[(&str, String)]) {
|
|
if app.code_language_selected >= options.len() {
|
|
return;
|
|
}
|
|
app.config.code_language = options[app.code_language_selected].0.to_string();
|
|
let _ = app.config.save();
|
|
if app.config.code_onboarding_done {
|
|
app.start_code_drill();
|
|
} else {
|
|
app.go_to_code_intro();
|
|
}
|
|
}
|
|
|
|
fn handle_passage_book_key(app: &mut App, key: KeyEvent) {
|
|
let options = passage_options();
|
|
match key.code {
|
|
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
app.passage_book_selected = app.passage_book_selected.saturating_sub(1);
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
if app.passage_book_selected + 1 < options.len() {
|
|
app.passage_book_selected += 1;
|
|
}
|
|
}
|
|
KeyCode::Char(ch) if ch.is_ascii_digit() => {
|
|
let idx = (ch as usize).saturating_sub('1' as usize);
|
|
if idx < options.len() {
|
|
app.passage_book_selected = idx;
|
|
let key = options[idx].0;
|
|
if !is_passage_option_disabled(app, key) {
|
|
confirm_passage_book_and_continue(app, &options);
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
if app.passage_book_selected < options.len() {
|
|
let key = options[app.passage_book_selected].0;
|
|
if !is_passage_option_disabled(app, key) {
|
|
confirm_passage_book_and_continue(app, &options);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn passage_option_requires_download(app: &App, key: &str) -> bool {
|
|
key != "all" && key != "builtin" && !is_book_cached(&app.config.passage_download_dir, key)
|
|
}
|
|
|
|
fn is_passage_option_disabled(app: &App, key: &str) -> bool {
|
|
!app.config.passage_downloads_enabled && passage_option_requires_download(app, key)
|
|
}
|
|
|
|
fn confirm_passage_book_and_continue(app: &mut App, options: &[(&'static str, String)]) {
|
|
if app.passage_book_selected >= options.len() {
|
|
return;
|
|
}
|
|
app.config.passage_book = options[app.passage_book_selected].0.to_string();
|
|
let _ = app.config.save();
|
|
|
|
if app.config.passage_onboarding_done {
|
|
app.start_passage_drill();
|
|
} else {
|
|
app.go_to_passage_intro();
|
|
}
|
|
}
|
|
|
|
fn handle_passage_intro_key(app: &mut App, key: KeyEvent) {
|
|
const INTRO_FIELDS: usize = 4;
|
|
|
|
if app.passage_intro_downloading {
|
|
return;
|
|
}
|
|
|
|
match key.code {
|
|
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
app.passage_intro_selected = app.passage_intro_selected.saturating_sub(1);
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
if app.passage_intro_selected + 1 < INTRO_FIELDS {
|
|
app.passage_intro_selected += 1;
|
|
}
|
|
}
|
|
KeyCode::Left | KeyCode::Char('h') => match app.passage_intro_selected {
|
|
0 => app.passage_intro_downloads_enabled = !app.passage_intro_downloads_enabled,
|
|
2 => {
|
|
app.passage_intro_paragraph_limit = match app.passage_intro_paragraph_limit {
|
|
0 => 500,
|
|
1 => 0,
|
|
n => n.saturating_sub(25).max(1),
|
|
};
|
|
}
|
|
_ => {}
|
|
},
|
|
KeyCode::Right | KeyCode::Char('l') => match app.passage_intro_selected {
|
|
0 => app.passage_intro_downloads_enabled = !app.passage_intro_downloads_enabled,
|
|
2 => {
|
|
app.passage_intro_paragraph_limit = match app.passage_intro_paragraph_limit {
|
|
0 => 1,
|
|
n if n >= 500 => 0,
|
|
n => n + 25,
|
|
};
|
|
}
|
|
_ => {}
|
|
},
|
|
KeyCode::Backspace => match app.passage_intro_selected {
|
|
1 => {
|
|
app.passage_intro_download_dir.pop();
|
|
}
|
|
2 => {
|
|
app.passage_intro_paragraph_limit /= 10;
|
|
}
|
|
_ => {}
|
|
},
|
|
KeyCode::Char(ch) => match app.passage_intro_selected {
|
|
1 if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
app.passage_intro_download_dir.push(ch);
|
|
}
|
|
2 if ch.is_ascii_digit() => {
|
|
let digit = (ch as u8 - b'0') as usize;
|
|
app.passage_intro_paragraph_limit = app
|
|
.passage_intro_paragraph_limit
|
|
.saturating_mul(10)
|
|
.saturating_add(digit)
|
|
.min(50_000);
|
|
}
|
|
_ => {}
|
|
},
|
|
KeyCode::Enter => {
|
|
if app.passage_intro_selected == 0 {
|
|
app.passage_intro_downloads_enabled = !app.passage_intro_downloads_enabled;
|
|
return;
|
|
}
|
|
if app.passage_intro_selected != 3 {
|
|
return;
|
|
}
|
|
|
|
app.config.passage_downloads_enabled = app.passage_intro_downloads_enabled;
|
|
app.config.passage_download_dir = app.passage_intro_download_dir.clone();
|
|
app.config.passage_paragraphs_per_book = app.passage_intro_paragraph_limit;
|
|
app.config.passage_onboarding_done = true;
|
|
let _ = app.config.save();
|
|
app.go_to_passage_book_select();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_passage_download_progress_key(app: &mut App, key: KeyEvent) {
|
|
match key.code {
|
|
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_code_intro_key(app: &mut App, key: KeyEvent) {
|
|
const INTRO_FIELDS: usize = 4;
|
|
|
|
if app.code_intro_downloading {
|
|
return;
|
|
}
|
|
|
|
match key.code {
|
|
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
app.code_intro_selected = app.code_intro_selected.saturating_sub(1);
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
if app.code_intro_selected + 1 < INTRO_FIELDS {
|
|
app.code_intro_selected += 1;
|
|
}
|
|
}
|
|
KeyCode::Left | KeyCode::Char('h') => match app.code_intro_selected {
|
|
0 => app.code_intro_downloads_enabled = !app.code_intro_downloads_enabled,
|
|
2 => {
|
|
app.code_intro_snippets_per_repo = match app.code_intro_snippets_per_repo {
|
|
0 => 200,
|
|
1 => 0,
|
|
n => n.saturating_sub(10).max(1),
|
|
};
|
|
}
|
|
_ => {}
|
|
},
|
|
KeyCode::Right | KeyCode::Char('l') => match app.code_intro_selected {
|
|
0 => app.code_intro_downloads_enabled = !app.code_intro_downloads_enabled,
|
|
2 => {
|
|
app.code_intro_snippets_per_repo = match app.code_intro_snippets_per_repo {
|
|
0 => 1,
|
|
n if n >= 200 => 0,
|
|
n => n + 10,
|
|
};
|
|
}
|
|
_ => {}
|
|
},
|
|
KeyCode::Backspace => match app.code_intro_selected {
|
|
1 => {
|
|
app.code_intro_download_dir.pop();
|
|
}
|
|
2 => {
|
|
app.code_intro_snippets_per_repo /= 10;
|
|
}
|
|
_ => {}
|
|
},
|
|
KeyCode::Char(ch) => match app.code_intro_selected {
|
|
1 if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
app.code_intro_download_dir.push(ch);
|
|
}
|
|
2 if ch.is_ascii_digit() => {
|
|
let digit = (ch as u8 - b'0') as usize;
|
|
app.code_intro_snippets_per_repo = app
|
|
.code_intro_snippets_per_repo
|
|
.saturating_mul(10)
|
|
.saturating_add(digit)
|
|
.min(10_000);
|
|
}
|
|
_ => {}
|
|
},
|
|
KeyCode::Enter => {
|
|
if app.code_intro_selected == 0 {
|
|
app.code_intro_downloads_enabled = !app.code_intro_downloads_enabled;
|
|
return;
|
|
}
|
|
if app.code_intro_selected != 3 {
|
|
return;
|
|
}
|
|
|
|
app.config.code_downloads_enabled = app.code_intro_downloads_enabled;
|
|
app.config.code_download_dir = app.code_intro_download_dir.clone();
|
|
app.config.code_snippets_per_repo = app.code_intro_snippets_per_repo;
|
|
app.config.code_onboarding_done = true;
|
|
let _ = app.config.save();
|
|
app.go_to_code_language_select();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_code_download_progress_key(app: &mut App, key: KeyEvent) {
|
|
match key.code {
|
|
KeyCode::Esc | KeyCode::Char('q') => {
|
|
app.cancel_code_download();
|
|
app.go_to_menu();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_skill_tree_key(app: &mut App, key: KeyEvent) {
|
|
const DETAIL_SCROLL_STEP: usize = 10;
|
|
let max_scroll = skill_tree_detail_max_scroll(app);
|
|
app.skill_tree_detail_scroll = app.skill_tree_detail_scroll.min(max_scroll);
|
|
let branches = selectable_branches();
|
|
match key.code {
|
|
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
app.skill_tree_selected = app.skill_tree_selected.saturating_sub(1);
|
|
app.skill_tree_detail_scroll = 0;
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
if app.skill_tree_selected + 1 < branches.len() {
|
|
app.skill_tree_selected += 1;
|
|
app.skill_tree_detail_scroll = 0;
|
|
}
|
|
}
|
|
KeyCode::PageUp => {
|
|
app.skill_tree_detail_scroll = app
|
|
.skill_tree_detail_scroll
|
|
.saturating_sub(DETAIL_SCROLL_STEP);
|
|
}
|
|
KeyCode::PageDown => {
|
|
let max_scroll = skill_tree_detail_max_scroll(app);
|
|
app.skill_tree_detail_scroll = app
|
|
.skill_tree_detail_scroll
|
|
.saturating_add(DETAIL_SCROLL_STEP)
|
|
.min(max_scroll);
|
|
}
|
|
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
app.skill_tree_detail_scroll = app
|
|
.skill_tree_detail_scroll
|
|
.saturating_sub(DETAIL_SCROLL_STEP);
|
|
}
|
|
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
let max_scroll = skill_tree_detail_max_scroll(app);
|
|
app.skill_tree_detail_scroll = app
|
|
.skill_tree_detail_scroll
|
|
.saturating_add(DETAIL_SCROLL_STEP)
|
|
.min(max_scroll);
|
|
}
|
|
KeyCode::Enter => {
|
|
if app.skill_tree_selected < branches.len() {
|
|
let branch_id = branches[app.skill_tree_selected];
|
|
let status = app.skill_tree.branch_status(branch_id).clone();
|
|
if status == engine::skill_tree::BranchStatus::Available
|
|
|| status == engine::skill_tree::BranchStatus::InProgress
|
|
{
|
|
app.start_branch_drill(branch_id);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn skill_tree_detail_max_scroll(app: &App) -> usize {
|
|
let (w, h) = crossterm::terminal::size().unwrap_or((120, 40));
|
|
let screen = Rect::new(0, 0, w, h);
|
|
let centered = skill_tree_popup_rect(screen);
|
|
let inner = Rect::new(
|
|
centered.x.saturating_add(1),
|
|
centered.y.saturating_add(1),
|
|
centered.width.saturating_sub(2),
|
|
centered.height.saturating_sub(2),
|
|
);
|
|
|
|
let branches = selectable_branches();
|
|
if branches.is_empty() {
|
|
return 0;
|
|
}
|
|
let branch_list_height = branches.len() as u16 * 2 + 1;
|
|
let layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(branch_list_height.min(inner.height.saturating_sub(6))),
|
|
Constraint::Length(1),
|
|
Constraint::Min(4),
|
|
Constraint::Length(2),
|
|
])
|
|
.split(inner);
|
|
let detail_height = layout.get(2).map(|r| r.height as usize).unwrap_or(0);
|
|
let selected = app
|
|
.skill_tree_selected
|
|
.min(branches.len().saturating_sub(1));
|
|
let total_lines = detail_line_count(branches[selected]);
|
|
total_lines.saturating_sub(detail_height)
|
|
}
|
|
|
|
fn skill_tree_popup_rect(area: Rect) -> Rect {
|
|
let percent_x = if area.width < 120 { 95 } else { 85 };
|
|
let percent_y = if area.height < 40 { 95 } else { 90 };
|
|
ui::layout::centered_rect(percent_x, percent_y, area)
|
|
}
|
|
|
|
fn render(frame: &mut ratatui::Frame, app: &App) {
|
|
let area = frame.area();
|
|
let colors = &app.theme.colors;
|
|
|
|
let bg = Block::default().style(Style::default().bg(colors.bg()));
|
|
frame.render_widget(bg, area);
|
|
|
|
// Milestone overlays are modal and shown before the underlying screen.
|
|
if let Some(milestone) = app.milestone_queue.front() {
|
|
render_milestone_overlay(frame, app, milestone);
|
|
return;
|
|
}
|
|
|
|
match app.screen {
|
|
AppScreen::Menu => render_menu(frame, app),
|
|
AppScreen::Drill => render_drill(frame, app),
|
|
AppScreen::DrillResult => render_result(frame, app),
|
|
AppScreen::StatsDashboard => render_stats(frame, app),
|
|
AppScreen::Settings => render_settings(frame, app),
|
|
AppScreen::SkillTree => render_skill_tree(frame, app),
|
|
AppScreen::CodeLanguageSelect => render_code_language_select(frame, app),
|
|
AppScreen::PassageBookSelect => render_passage_book_select(frame, app),
|
|
AppScreen::PassageIntro => render_passage_intro(frame, app),
|
|
AppScreen::PassageDownloadProgress => render_passage_download_progress(frame, app),
|
|
AppScreen::CodeIntro => render_code_intro(frame, app),
|
|
AppScreen::CodeDownloadProgress => render_code_download_progress(frame, app),
|
|
AppScreen::Keyboard => render_keyboard_explorer(frame, app),
|
|
}
|
|
}
|
|
|
|
fn render_menu(frame: &mut ratatui::Frame, app: &App) {
|
|
let area = frame.area();
|
|
let colors = &app.theme.colors;
|
|
|
|
let layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(3),
|
|
Constraint::Min(0),
|
|
Constraint::Length(1),
|
|
])
|
|
.split(area);
|
|
|
|
let streak_text = if app.profile.streak_days > 0 {
|
|
format!(" | {} day streak", app.profile.streak_days)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let total_keys = app.skill_tree.total_unique_keys;
|
|
let unlocked = app.skill_tree.total_unlocked_count();
|
|
let mastered = app.skill_tree.total_confident_keys(&app.ranked_key_stats);
|
|
let header_info = format!(
|
|
" Key Progress {unlocked}/{total_keys} ({mastered} mastered) | Target {} WPM{}",
|
|
app.config.target_wpm, streak_text,
|
|
);
|
|
let header = Paragraph::new(Line::from(vec![
|
|
Span::styled(
|
|
" keydr ",
|
|
Style::default()
|
|
.fg(colors.header_fg())
|
|
.bg(colors.header_bg())
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
Span::styled(
|
|
&*header_info,
|
|
Style::default()
|
|
.fg(colors.text_pending())
|
|
.bg(colors.header_bg()),
|
|
),
|
|
]))
|
|
.style(Style::default().bg(colors.header_bg()));
|
|
frame.render_widget(header, layout[0]);
|
|
|
|
let menu_area = ui::layout::centered_rect(50, 80, layout[1]);
|
|
frame.render_widget(&app.menu, menu_area);
|
|
|
|
let footer = Paragraph::new(Line::from(vec![Span::styled(
|
|
" [1-3] Start [t] Skill Tree [b] Keyboard [s] Stats [c] Settings [q] Quit ",
|
|
Style::default().fg(colors.text_pending()),
|
|
)]));
|
|
frame.render_widget(footer, layout[2]);
|
|
}
|
|
|
|
fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
|
let area = frame.area();
|
|
let colors = &app.theme.colors;
|
|
|
|
if let Some(ref drill) = app.drill {
|
|
let app_layout = AppLayout::new(area);
|
|
let tier = app_layout.tier;
|
|
|
|
let mode_name = match app.drill_mode {
|
|
DrillMode::Adaptive => "Adaptive",
|
|
DrillMode::Code => "Code (Unranked)",
|
|
DrillMode::Passage => "Passage (Unranked)",
|
|
};
|
|
|
|
// For medium/narrow: show compact stats in header
|
|
if !tier.show_sidebar() {
|
|
let wpm = drill.wpm();
|
|
let accuracy = drill.accuracy();
|
|
let errors = drill.typo_count();
|
|
let header_text =
|
|
format!(" {mode_name} | WPM: {wpm:.0} | Acc: {accuracy:.1}% | Errors: {errors}");
|
|
let header = Paragraph::new(Line::from(Span::styled(
|
|
&*header_text,
|
|
Style::default()
|
|
.fg(colors.header_fg())
|
|
.bg(colors.header_bg())
|
|
.add_modifier(Modifier::BOLD),
|
|
)))
|
|
.style(Style::default().bg(colors.header_bg()));
|
|
frame.render_widget(header, app_layout.header);
|
|
} else {
|
|
let header_title = format!(" {mode_name} Drill ");
|
|
let focus_text = if app.drill_mode == DrillMode::Adaptive {
|
|
let focused = app
|
|
.skill_tree
|
|
.focused_key(app.drill_scope, &app.ranked_key_stats);
|
|
if let Some(focused) = focused {
|
|
format!(" | Focus: '{focused}'")
|
|
} else {
|
|
String::new()
|
|
}
|
|
} else {
|
|
String::new()
|
|
};
|
|
let header = Paragraph::new(Line::from(vec![
|
|
Span::styled(
|
|
&*header_title,
|
|
Style::default()
|
|
.fg(colors.header_fg())
|
|
.bg(colors.header_bg())
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
Span::styled(
|
|
&*focus_text,
|
|
Style::default()
|
|
.fg(colors.focused_key())
|
|
.bg(colors.header_bg()),
|
|
),
|
|
]))
|
|
.style(Style::default().bg(colors.header_bg()));
|
|
frame.render_widget(header, app_layout.header);
|
|
}
|
|
|
|
// Build main area constraints based on tier
|
|
let show_kbd = tier.show_keyboard(area.height);
|
|
let show_progress = tier.show_progress_bar(area.height);
|
|
|
|
// Compute active branch count for progress area height
|
|
let active_branches: Vec<engine::skill_tree::BranchId> =
|
|
engine::skill_tree::BranchId::all()
|
|
.iter()
|
|
.copied()
|
|
.filter(|&id| {
|
|
matches!(
|
|
app.skill_tree.branch_status(id),
|
|
engine::skill_tree::BranchStatus::InProgress
|
|
| engine::skill_tree::BranchStatus::Complete
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
let progress_height = if show_progress && area.height >= 25 {
|
|
(active_branches.len().min(6) as u16 + 1).max(2) // +1 for overall line
|
|
} else if show_progress && area.height >= 20 {
|
|
2 // active branch + overall
|
|
} else if show_progress {
|
|
1 // active branch only
|
|
} else {
|
|
0
|
|
};
|
|
|
|
let kbd_height = if show_kbd {
|
|
if tier.compact_keyboard() {
|
|
6 // 3 rows + 2 border + 1 modifier space
|
|
} else {
|
|
8 // 5 rows (4 + space bar) + 2 border + 1 spacing
|
|
}
|
|
} else {
|
|
0
|
|
};
|
|
|
|
let mut constraints: Vec<Constraint> = vec![Constraint::Min(5)];
|
|
if progress_height > 0 {
|
|
constraints.push(Constraint::Length(progress_height));
|
|
}
|
|
if show_kbd {
|
|
constraints.push(Constraint::Length(kbd_height));
|
|
}
|
|
|
|
let main_layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints(constraints)
|
|
.split(app_layout.main);
|
|
|
|
let typing = TypingArea::new(drill, app.theme);
|
|
frame.render_widget(typing, main_layout[0]);
|
|
|
|
let mut idx = 1;
|
|
if progress_height > 0 {
|
|
if app.drill_mode == DrillMode::Adaptive {
|
|
let progress_widget = ui::components::branch_progress_list::BranchProgressList {
|
|
skill_tree: &app.skill_tree,
|
|
key_stats: &app.ranked_key_stats,
|
|
drill_scope: app.drill_scope,
|
|
active_branches: &active_branches,
|
|
theme: app.theme,
|
|
height: progress_height,
|
|
};
|
|
frame.render_widget(progress_widget, main_layout[idx]);
|
|
} else {
|
|
let source = app.drill_source_info.as_deref().unwrap_or("unknown source");
|
|
let label = if app.drill_mode == DrillMode::Code {
|
|
" Code source "
|
|
} else {
|
|
" Passage source "
|
|
};
|
|
let source_info = Paragraph::new(Line::from(vec![
|
|
Span::styled(
|
|
label,
|
|
Style::default()
|
|
.fg(colors.accent())
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
Span::styled(source, Style::default().fg(colors.text_pending())),
|
|
]));
|
|
frame.render_widget(source_info, main_layout[idx]);
|
|
}
|
|
idx += 1;
|
|
}
|
|
|
|
if show_kbd {
|
|
let next_char = drill.target.get(drill.cursor).copied();
|
|
let unlocked_keys = app.skill_tree.unlocked_keys(app.drill_scope);
|
|
let kbd = KeyboardDiagram::new(
|
|
next_char,
|
|
&unlocked_keys,
|
|
&app.depressed_keys,
|
|
app.theme,
|
|
&app.keyboard_model,
|
|
)
|
|
.compact(tier.compact_keyboard())
|
|
.shift_held(app.shift_held)
|
|
.caps_lock(app.caps_lock);
|
|
frame.render_widget(kbd, main_layout[idx]);
|
|
}
|
|
|
|
if let Some(sidebar_area) = app_layout.sidebar {
|
|
let sidebar = StatsSidebar::new(
|
|
drill,
|
|
app.last_result.as_ref(),
|
|
&app.drill_history,
|
|
app.config.target_wpm,
|
|
app.theme,
|
|
);
|
|
frame.render_widget(sidebar, sidebar_area);
|
|
}
|
|
|
|
let footer = Paragraph::new(Line::from(Span::styled(
|
|
" [ESC] End drill [Backspace] Delete ",
|
|
Style::default().fg(colors.text_pending()),
|
|
)));
|
|
frame.render_widget(footer, app_layout.footer);
|
|
|
|
}
|
|
}
|
|
|
|
fn render_milestone_overlay(
|
|
frame: &mut ratatui::Frame,
|
|
app: &App,
|
|
milestone: &app::KeyMilestonePopup,
|
|
) {
|
|
let area = frame.area();
|
|
let colors = &app.theme.colors;
|
|
|
|
// Determine overlay size based on terminal height:
|
|
// Large (>=25): full keyboard diagram
|
|
// Medium (>=15): compact keyboard diagram
|
|
// Small (<15): text only
|
|
let kbd_mode = overlay_keyboard_mode(area.height);
|
|
let overlay_height = match kbd_mode {
|
|
2 => 18u16.min(area.height.saturating_sub(2)),
|
|
1 => 14u16.min(area.height.saturating_sub(2)),
|
|
_ => 10u16.min(area.height.saturating_sub(2)),
|
|
};
|
|
let overlay_width = 60u16.min(area.width.saturating_sub(4));
|
|
|
|
let left = area.x + (area.width.saturating_sub(overlay_width)) / 2;
|
|
let top = area.y + (area.height.saturating_sub(overlay_height)) / 2;
|
|
let overlay_area = Rect::new(left, top, overlay_width, overlay_height);
|
|
|
|
// Clear the area behind the overlay
|
|
frame.render_widget(ratatui::widgets::Clear, overlay_area);
|
|
|
|
let title = match milestone.kind {
|
|
MilestoneKind::Unlock => " Key Unlocked! ",
|
|
MilestoneKind::Mastery => " Key Mastered! ",
|
|
};
|
|
|
|
let block = Block::bordered()
|
|
.title(title)
|
|
.border_style(Style::default().fg(colors.accent()))
|
|
.style(Style::default().bg(colors.bg()));
|
|
let inner = block.inner(overlay_area);
|
|
block.render(overlay_area, frame.buffer_mut());
|
|
|
|
let mut lines: Vec<Line> = Vec::new();
|
|
|
|
// Key display line
|
|
let key_action = match milestone.kind {
|
|
MilestoneKind::Unlock => "unlocked",
|
|
MilestoneKind::Mastery => "mastered",
|
|
};
|
|
|
|
let key_names: Vec<String> = milestone
|
|
.keys
|
|
.iter()
|
|
.map(|&ch| {
|
|
let name = keyboard::display::key_display_name(ch);
|
|
if name.is_empty() {
|
|
format!("'{ch}'")
|
|
} else {
|
|
name.to_string()
|
|
}
|
|
})
|
|
.collect();
|
|
let keys_str = key_names.join(", ");
|
|
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from(Span::styled(
|
|
format!(" You {key_action}: {keys_str}"),
|
|
Style::default()
|
|
.fg(colors.accent())
|
|
.add_modifier(Modifier::BOLD),
|
|
)));
|
|
|
|
// Finger info (for unlocks)
|
|
if matches!(milestone.kind, MilestoneKind::Unlock) {
|
|
for (ch, finger_desc) in &milestone.finger_info {
|
|
let key_label = {
|
|
let name = keyboard::display::key_display_name(*ch);
|
|
if name.is_empty() {
|
|
format!("'{ch}'")
|
|
} else {
|
|
name.to_string()
|
|
}
|
|
};
|
|
lines.push(Line::from(Span::styled(
|
|
format!(" {key_label}: Use your {finger_desc}"),
|
|
Style::default().fg(colors.fg()),
|
|
)));
|
|
|
|
// Shift key guidance for shifted characters
|
|
let fa = app.keyboard_model.finger_for_char(*ch);
|
|
if ch.is_ascii_uppercase()
|
|
|| (!ch.is_ascii_lowercase()
|
|
&& !ch.is_ascii_digit()
|
|
&& !ch.is_ascii_whitespace()
|
|
&& *ch != ' ')
|
|
{
|
|
let shift_hint = if fa.hand == keyboard::finger::Hand::Left {
|
|
"Hold Right Shift (right pinky)"
|
|
} else {
|
|
"Hold Left Shift (left pinky)"
|
|
};
|
|
lines.push(Line::from(Span::styled(
|
|
format!(" {shift_hint}"),
|
|
Style::default().fg(colors.text_pending()),
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Encouraging message (randomly selected at creation time)
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from(Span::styled(
|
|
format!(" {}", milestone.message),
|
|
Style::default().fg(colors.focused_key()),
|
|
)));
|
|
|
|
// Keyboard diagram (if space permits)
|
|
if kbd_mode > 0 {
|
|
let min_kbd_height: u16 = if kbd_mode == 2 { 6 } else { 4 };
|
|
let remaining = inner.height.saturating_sub(lines.len() as u16 + 2);
|
|
if remaining >= min_kbd_height {
|
|
let kbd_y_start = inner.y + lines.len() as u16 + 1;
|
|
let kbd_height = remaining.min(if kbd_mode == 2 { 8 } else { 6 });
|
|
let kbd_area = Rect::new(inner.x, kbd_y_start, inner.width, kbd_height);
|
|
let milestone_key = milestone.keys.first().copied();
|
|
let unlocked_keys = app.skill_tree.unlocked_keys(app.drill_scope);
|
|
let is_shifted = milestone_key.is_some_and(|ch| {
|
|
ch.is_ascii_uppercase()
|
|
|| app.keyboard_model.shifted_to_base(ch).is_some()
|
|
});
|
|
let kbd = KeyboardDiagram::new(
|
|
None,
|
|
&unlocked_keys,
|
|
&app.depressed_keys,
|
|
app.theme,
|
|
&app.keyboard_model,
|
|
)
|
|
.selected_key(milestone_key)
|
|
.compact(kbd_mode == 1)
|
|
.shift_held(is_shifted)
|
|
.caps_lock(app.caps_lock);
|
|
frame.render_widget(kbd, kbd_area);
|
|
}
|
|
}
|
|
|
|
// Render the text content
|
|
let text_area = Rect::new(
|
|
inner.x,
|
|
inner.y,
|
|
inner.width,
|
|
inner.height.saturating_sub(1),
|
|
);
|
|
Paragraph::new(lines).render(text_area, frame.buffer_mut());
|
|
|
|
// Footer
|
|
let footer_y = inner.y + inner.height.saturating_sub(1);
|
|
if footer_y < inner.y + inner.height {
|
|
let footer_area = Rect::new(inner.x, footer_y, inner.width, 1);
|
|
let footer = Paragraph::new(Line::from(Span::styled(
|
|
" Press any key to continue",
|
|
Style::default().fg(colors.text_pending()),
|
|
)));
|
|
frame.render_widget(footer, footer_area);
|
|
}
|
|
}
|
|
|
|
fn overlay_keyboard_mode(height: u16) -> u8 {
|
|
if height >= 25 {
|
|
2 // full
|
|
} else if height >= 15 {
|
|
1 // compact
|
|
} else {
|
|
0 // text only
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod review_tests {
|
|
use super::*;
|
|
use crate::session::result::DrillResult;
|
|
use chrono::{TimeDelta, Utc};
|
|
|
|
fn test_result(ts_offset_secs: i64) -> DrillResult {
|
|
DrillResult {
|
|
wpm: 60.0,
|
|
cpm: 300.0,
|
|
accuracy: 98.0,
|
|
correct: 49,
|
|
incorrect: 1,
|
|
total_chars: 50,
|
|
elapsed_secs: 10.0,
|
|
timestamp: Utc::now() + TimeDelta::seconds(ts_offset_secs),
|
|
per_key_times: vec![],
|
|
drill_mode: "adaptive".to_string(),
|
|
ranked: true,
|
|
partial: false,
|
|
completion_percent: 100.0,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn milestone_overlay_blocks_underlying_input() {
|
|
let mut app = App::new();
|
|
app.screen = AppScreen::Drill;
|
|
app.drill = Some(crate::session::drill::DrillState::new("abc"));
|
|
app.milestone_queue
|
|
.push_back(crate::app::KeyMilestonePopup {
|
|
kind: crate::app::MilestoneKind::Unlock,
|
|
keys: vec!['a'],
|
|
finger_info: vec![('a', "left pinky".to_string())],
|
|
message: "msg",
|
|
});
|
|
|
|
let before_cursor = app.drill.as_ref().map(|d| d.cursor).unwrap_or(0);
|
|
handle_key(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
|
let after_cursor = app.drill.as_ref().map(|d| d.cursor).unwrap_or(0);
|
|
|
|
assert_eq!(before_cursor, after_cursor);
|
|
assert!(app.milestone_queue.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn milestone_queue_chains_before_result_actions() {
|
|
let mut app = App::new();
|
|
app.screen = AppScreen::DrillResult;
|
|
app.milestone_queue
|
|
.push_back(crate::app::KeyMilestonePopup {
|
|
kind: crate::app::MilestoneKind::Unlock,
|
|
keys: vec!['a'],
|
|
finger_info: vec![('a', "left pinky".to_string())],
|
|
message: "msg1",
|
|
});
|
|
app.milestone_queue
|
|
.push_back(crate::app::KeyMilestonePopup {
|
|
kind: crate::app::MilestoneKind::Mastery,
|
|
keys: vec!['a'],
|
|
finger_info: vec![('a', "left pinky".to_string())],
|
|
message: "msg2",
|
|
});
|
|
|
|
handle_key(&mut app, KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
|
|
assert_eq!(app.screen, AppScreen::DrillResult);
|
|
assert_eq!(app.milestone_queue.len(), 1);
|
|
|
|
handle_key(&mut app, KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
|
|
assert_eq!(app.screen, AppScreen::DrillResult);
|
|
assert!(app.milestone_queue.is_empty());
|
|
|
|
// Now normal result action should apply.
|
|
handle_key(&mut app, KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
|
|
assert_eq!(app.screen, AppScreen::Menu);
|
|
}
|
|
|
|
#[test]
|
|
fn overlay_mode_height_boundaries() {
|
|
assert_eq!(overlay_keyboard_mode(14), 0);
|
|
assert_eq!(overlay_keyboard_mode(15), 1);
|
|
assert_eq!(overlay_keyboard_mode(24), 1);
|
|
assert_eq!(overlay_keyboard_mode(25), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn result_delete_shortcut_opens_confirmation_for_latest() {
|
|
let mut app = App::new();
|
|
app.screen = AppScreen::DrillResult;
|
|
app.last_result = Some(test_result(2));
|
|
app.drill_history = vec![test_result(1), test_result(2)];
|
|
app.history_selected = 1;
|
|
|
|
handle_key(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
|
|
|
assert!(app.history_confirm_delete);
|
|
assert_eq!(app.history_selected, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn result_delete_confirmation_yes_deletes_latest() {
|
|
let mut app = App::new();
|
|
app.screen = AppScreen::DrillResult;
|
|
app.last_result = Some(test_result(3));
|
|
let older = test_result(1);
|
|
let newer = test_result(2);
|
|
app.drill_history = vec![older.clone(), newer.clone()];
|
|
|
|
handle_key(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
|
handle_key(&mut app, KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
|
|
|
assert!(!app.history_confirm_delete);
|
|
assert_eq!(app.drill_history.len(), 1);
|
|
assert_eq!(app.drill_history[0].timestamp, older.timestamp);
|
|
}
|
|
|
|
#[test]
|
|
fn result_delete_confirmation_cancel_keeps_history() {
|
|
let mut app = App::new();
|
|
app.screen = AppScreen::DrillResult;
|
|
app.last_result = Some(test_result(2));
|
|
app.drill_history = vec![test_result(1), test_result(2)];
|
|
|
|
handle_key(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
|
handle_key(&mut app, KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
|
|
|
assert!(!app.history_confirm_delete);
|
|
assert_eq!(app.drill_history.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn result_continue_shortcuts_start_next_drill() {
|
|
let mut app = App::new();
|
|
app.screen = AppScreen::DrillResult;
|
|
app.last_result = Some(test_result(2));
|
|
|
|
handle_key(&mut app, KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
|
assert_eq!(app.screen, AppScreen::Drill);
|
|
|
|
app.screen = AppScreen::DrillResult;
|
|
handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert_eq!(app.screen, AppScreen::Drill);
|
|
|
|
app.screen = AppScreen::DrillResult;
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(app.screen, AppScreen::Drill);
|
|
}
|
|
|
|
#[test]
|
|
fn result_continue_code_uses_last_language_params() {
|
|
let mut app = App::new();
|
|
app.screen = AppScreen::DrillResult;
|
|
app.last_result = Some(test_result(2));
|
|
app.drill_mode = DrillMode::Code;
|
|
app.config.code_downloads_enabled = false;
|
|
app.config.code_language = "python".to_string();
|
|
app.last_code_drill_language = Some("rust".to_string());
|
|
|
|
handle_key(&mut app, KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
|
|
|
assert_eq!(app.screen, AppScreen::Drill);
|
|
assert_eq!(app.drill_mode, DrillMode::Code);
|
|
assert_eq!(app.last_code_drill_language.as_deref(), Some("rust"));
|
|
}
|
|
|
|
/// Helper: count how many settings modal/edit flags are active
|
|
fn modal_edit_count(app: &App) -> usize {
|
|
[
|
|
app.settings_confirm_import,
|
|
app.settings_export_conflict,
|
|
app.settings_editing_export_path,
|
|
app.settings_editing_import_path,
|
|
app.settings_editing_download_dir,
|
|
]
|
|
.iter()
|
|
.filter(|&&f| f)
|
|
.count()
|
|
}
|
|
|
|
#[test]
|
|
fn settings_modal_invariant_enter_export_path_clears_others() {
|
|
let mut app = App::new();
|
|
app.screen = AppScreen::Settings;
|
|
|
|
// First, activate import confirmation
|
|
app.settings_selected = 15; // Import Data
|
|
handle_settings_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
|
);
|
|
assert!(app.settings_confirm_import);
|
|
assert!(modal_edit_count(&app) <= 1);
|
|
|
|
// Cancel it
|
|
handle_settings_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
|
);
|
|
assert!(!app.settings_confirm_import);
|
|
|
|
// Enter export path editing
|
|
app.settings_selected = 12; // Export Path
|
|
handle_settings_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
|
);
|
|
assert!(app.settings_editing_export_path);
|
|
assert!(modal_edit_count(&app) <= 1);
|
|
|
|
// Esc out
|
|
handle_settings_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
|
);
|
|
assert!(!app.settings_editing_export_path);
|
|
}
|
|
|
|
#[test]
|
|
fn settings_modal_invariant_enter_import_path_clears_others() {
|
|
let mut app = App::new();
|
|
app.screen = AppScreen::Settings;
|
|
|
|
// Activate export path editing first
|
|
app.settings_selected = 12;
|
|
handle_settings_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
|
);
|
|
assert!(app.settings_editing_export_path);
|
|
|
|
// Esc out, then enter import path editing
|
|
handle_settings_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
|
);
|
|
app.settings_selected = 14; // Import Path
|
|
handle_settings_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
|
);
|
|
assert!(app.settings_editing_import_path);
|
|
assert!(!app.settings_editing_export_path);
|
|
assert!(modal_edit_count(&app) <= 1);
|
|
}
|
|
|
|
#[test]
|
|
fn settings_confirm_import_dialog_y_n_esc() {
|
|
let mut app = App::new();
|
|
app.screen = AppScreen::Settings;
|
|
|
|
// Trigger import confirmation
|
|
app.settings_selected = 15;
|
|
handle_settings_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
|
);
|
|
assert!(app.settings_confirm_import);
|
|
|
|
// 'n' cancels
|
|
handle_settings_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE),
|
|
);
|
|
assert!(!app.settings_confirm_import);
|
|
|
|
// Trigger again
|
|
app.settings_selected = 15;
|
|
handle_settings_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
|
);
|
|
assert!(app.settings_confirm_import);
|
|
|
|
// Esc cancels
|
|
handle_settings_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
|
);
|
|
assert!(!app.settings_confirm_import);
|
|
}
|
|
|
|
#[test]
|
|
fn settings_status_message_dismissed_on_keypress() {
|
|
let mut app = App::new();
|
|
app.screen = AppScreen::Settings;
|
|
|
|
// Set a status message
|
|
app.settings_status_message = Some(crate::app::StatusMessage {
|
|
kind: StatusKind::Success,
|
|
text: "test".to_string(),
|
|
});
|
|
|
|
// Any keypress should dismiss it
|
|
handle_settings_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
|
|
);
|
|
assert!(app.settings_status_message.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn smart_rename_canonical_filename() {
|
|
use crate::app::next_available_path;
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
let base = dir.path();
|
|
|
|
// Create base file
|
|
let base_path = base.join("keydr-export-2026-01-01.json");
|
|
std::fs::write(&base_path, "{}").unwrap();
|
|
|
|
// First rename: picks -1
|
|
let result = next_available_path(base_path.to_str().unwrap());
|
|
assert!(result.ends_with("keydr-export-2026-01-01-1.json"));
|
|
|
|
// Create -1
|
|
std::fs::write(base.join("keydr-export-2026-01-01-1.json"), "{}").unwrap();
|
|
|
|
// From base: picks -2
|
|
let result = next_available_path(base_path.to_str().unwrap());
|
|
assert!(result.ends_with("keydr-export-2026-01-01-2.json"));
|
|
|
|
// From -1 path: normalizes to base stem and picks -2
|
|
let path_1 = base.join("keydr-export-2026-01-01-1.json");
|
|
let result = next_available_path(path_1.to_str().unwrap());
|
|
assert!(result.ends_with("keydr-export-2026-01-01-2.json"));
|
|
}
|
|
|
|
#[test]
|
|
fn smart_rename_custom_filename() {
|
|
use crate::app::next_available_path;
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
let base = dir.path();
|
|
|
|
let custom_path = base.join("my-backup.json");
|
|
std::fs::write(&custom_path, "{}").unwrap();
|
|
|
|
let result = next_available_path(custom_path.to_str().unwrap());
|
|
assert!(result.ends_with("my-backup-1.json"));
|
|
|
|
std::fs::write(base.join("my-backup-1.json"), "{}").unwrap();
|
|
let result = next_available_path(custom_path.to_str().unwrap());
|
|
assert!(result.ends_with("my-backup-2.json"));
|
|
}
|
|
}
|
|
|
|
fn render_result(frame: &mut ratatui::Frame, app: &App) {
|
|
let area = frame.area();
|
|
|
|
if let Some(ref result) = app.last_result {
|
|
let centered = ui::layout::centered_rect(60, 70, area);
|
|
let dashboard = Dashboard::new(result, app.theme);
|
|
frame.render_widget(dashboard, centered);
|
|
|
|
if app.history_confirm_delete && !app.drill_history.is_empty() {
|
|
let colors = &app.theme.colors;
|
|
let dialog_width = 34u16;
|
|
let dialog_height = 5u16;
|
|
let dialog_x = area.x + area.width.saturating_sub(dialog_width) / 2;
|
|
let dialog_y = area.y + area.height.saturating_sub(dialog_height) / 2;
|
|
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
|
|
|
|
let idx = app.drill_history.len().saturating_sub(app.history_selected);
|
|
let dialog_text = format!("Delete session #{idx}? (y/n)");
|
|
|
|
frame.render_widget(ratatui::widgets::Clear, dialog_area);
|
|
let dialog = Paragraph::new(vec![
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
format!(" {dialog_text} "),
|
|
Style::default().fg(colors.fg()),
|
|
)),
|
|
])
|
|
.style(Style::default().bg(colors.bg()))
|
|
.block(
|
|
Block::bordered()
|
|
.title(" Confirm ")
|
|
.border_style(Style::default().fg(colors.error()))
|
|
.style(Style::default().bg(colors.bg())),
|
|
);
|
|
frame.render_widget(dialog, dialog_area);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_stats(frame: &mut ratatui::Frame, app: &App) {
|
|
let area = frame.area();
|
|
let dashboard = StatsDashboard::new(
|
|
&app.drill_history,
|
|
&app.key_stats,
|
|
app.stats_tab,
|
|
app.config.target_wpm,
|
|
app.skill_tree.total_unlocked_count(),
|
|
app.skill_tree.total_confident_keys(&app.ranked_key_stats),
|
|
app.skill_tree.total_unique_keys,
|
|
app.theme,
|
|
app.history_selected,
|
|
app.history_confirm_delete,
|
|
&app.keyboard_model,
|
|
);
|
|
frame.render_widget(dashboard, area);
|
|
}
|
|
|
|
fn render_settings(frame: &mut ratatui::Frame, app: &App) {
|
|
let area = frame.area();
|
|
let colors = &app.theme.colors;
|
|
|
|
let centered = ui::layout::centered_rect(60, 80, area);
|
|
|
|
let block = Block::bordered()
|
|
.title(" Settings ")
|
|
.border_style(Style::default().fg(colors.accent()))
|
|
.style(Style::default().bg(colors.bg()));
|
|
let inner = block.inner(centered);
|
|
block.render(centered, frame.buffer_mut());
|
|
|
|
let fields: Vec<(String, String, bool)> = vec![
|
|
(
|
|
"Target WPM".to_string(),
|
|
format!("{}", app.config.target_wpm),
|
|
false,
|
|
),
|
|
("Theme".to_string(), app.config.theme.clone(), false),
|
|
(
|
|
"Word Count".to_string(),
|
|
format!("{}", app.config.word_count),
|
|
false,
|
|
),
|
|
(
|
|
"Code Language".to_string(),
|
|
app.config.code_language.clone(),
|
|
false,
|
|
),
|
|
(
|
|
"Code Downloads".to_string(),
|
|
if app.config.code_downloads_enabled {
|
|
"On".to_string()
|
|
} else {
|
|
"Off".to_string()
|
|
},
|
|
false,
|
|
),
|
|
(
|
|
"Code Download Dir".to_string(),
|
|
app.config.code_download_dir.clone(),
|
|
true, // path field
|
|
),
|
|
(
|
|
"Snippets per Repo".to_string(),
|
|
if app.config.code_snippets_per_repo == 0 {
|
|
"Unlimited".to_string()
|
|
} else {
|
|
format!("{}", app.config.code_snippets_per_repo)
|
|
},
|
|
false,
|
|
),
|
|
(
|
|
"Download Code Now".to_string(),
|
|
"Run downloader".to_string(),
|
|
false,
|
|
),
|
|
(
|
|
"Passage Downloads".to_string(),
|
|
if app.config.passage_downloads_enabled {
|
|
"On".to_string()
|
|
} else {
|
|
"Off".to_string()
|
|
},
|
|
false,
|
|
),
|
|
(
|
|
"Passage Download Dir".to_string(),
|
|
app.config.passage_download_dir.clone(),
|
|
true, // path field
|
|
),
|
|
(
|
|
"Paragraphs per Book".to_string(),
|
|
if app.config.passage_paragraphs_per_book == 0 {
|
|
"Whole book".to_string()
|
|
} else {
|
|
format!("{}", app.config.passage_paragraphs_per_book)
|
|
},
|
|
false,
|
|
),
|
|
(
|
|
"Download Passages Now".to_string(),
|
|
"Run downloader".to_string(),
|
|
false,
|
|
),
|
|
(
|
|
"Export Path".to_string(),
|
|
app.settings_export_path.clone(),
|
|
true, // path field
|
|
),
|
|
(
|
|
"Export Data".to_string(),
|
|
"Export now".to_string(),
|
|
false,
|
|
),
|
|
(
|
|
"Import Path".to_string(),
|
|
app.settings_import_path.clone(),
|
|
true, // path field
|
|
),
|
|
(
|
|
"Import Data".to_string(),
|
|
"Import now".to_string(),
|
|
false,
|
|
),
|
|
];
|
|
|
|
let header_height = if inner.height > 0 { 1 } else { 0 };
|
|
let footer_height = if inner.height > header_height { 1 } else { 0 };
|
|
let field_height = inner.height.saturating_sub(header_height + footer_height);
|
|
|
|
let layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(header_height),
|
|
Constraint::Length(field_height),
|
|
Constraint::Length(footer_height),
|
|
])
|
|
.split(inner);
|
|
|
|
let header = Paragraph::new(Line::from(Span::styled(
|
|
" Use arrows to navigate, Enter/Right to change, ESC to save & exit",
|
|
Style::default().fg(colors.text_pending()),
|
|
)));
|
|
header.render(layout[0], frame.buffer_mut());
|
|
|
|
let row_height = 2u16;
|
|
let visible_rows = (layout[1].height / row_height).max(1) as usize;
|
|
let max_start = fields.len().saturating_sub(visible_rows);
|
|
let start = app
|
|
.settings_selected
|
|
.saturating_sub(visible_rows.saturating_sub(1))
|
|
.min(max_start);
|
|
let end = (start + visible_rows).min(fields.len());
|
|
let visible_fields = &fields[start..end];
|
|
|
|
let field_layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints(
|
|
visible_fields
|
|
.iter()
|
|
.map(|_| Constraint::Length(row_height))
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
.split(layout[1]);
|
|
|
|
for (row, (label, value, is_path)) in visible_fields.iter().enumerate() {
|
|
let i = start + row;
|
|
let is_selected = i == app.settings_selected;
|
|
let indicator = if is_selected { " > " } else { " " };
|
|
|
|
let label_text = format!("{indicator}{label}:");
|
|
let is_button = i == 7 || i == 11 || i == 13 || i == 15;
|
|
let value_text = if is_button {
|
|
format!(" [ {value} ]")
|
|
} else {
|
|
format!(" < {value} >")
|
|
};
|
|
|
|
let label_style = Style::default()
|
|
.fg(if is_selected {
|
|
colors.accent()
|
|
} else {
|
|
colors.fg()
|
|
})
|
|
.add_modifier(if is_selected {
|
|
Modifier::BOLD
|
|
} else {
|
|
Modifier::empty()
|
|
});
|
|
|
|
let value_style = Style::default().fg(if is_selected {
|
|
colors.focused_key()
|
|
} else {
|
|
colors.text_pending()
|
|
});
|
|
|
|
let is_editing_this_path = is_selected && *is_path && (
|
|
app.settings_editing_download_dir
|
|
|| app.settings_editing_export_path
|
|
|| app.settings_editing_import_path
|
|
);
|
|
let lines = if *is_path {
|
|
let path_line = if is_editing_this_path {
|
|
format!(" {value}_")
|
|
} else {
|
|
format!(" {value}")
|
|
};
|
|
vec![
|
|
Line::from(Span::styled(
|
|
if is_editing_this_path {
|
|
format!("{indicator}{label}: (editing)")
|
|
} else {
|
|
label_text
|
|
},
|
|
label_style,
|
|
)),
|
|
Line::from(Span::styled(path_line, value_style)),
|
|
]
|
|
} else {
|
|
vec![
|
|
Line::from(Span::styled(label_text, label_style)),
|
|
Line::from(Span::styled(value_text, value_style)),
|
|
]
|
|
};
|
|
Paragraph::new(lines).render(field_layout[row], frame.buffer_mut());
|
|
}
|
|
|
|
let any_path_editing = app.settings_editing_download_dir
|
|
|| app.settings_editing_export_path
|
|
|| app.settings_editing_import_path;
|
|
let footer_hints: Vec<&str> = if any_path_editing {
|
|
vec!["Editing path:", "[Type/Backspace] Modify", "[ESC] Done editing"]
|
|
} else {
|
|
vec![
|
|
"[ESC] Save & back",
|
|
"[Enter/arrows] Change value",
|
|
"[Enter on path] Edit",
|
|
]
|
|
};
|
|
let footer_lines: Vec<Line> = pack_hint_lines(&footer_hints, layout[2].width as usize)
|
|
.into_iter()
|
|
.map(|line| Line::from(Span::styled(line, Style::default().fg(colors.accent()))))
|
|
.collect();
|
|
Paragraph::new(footer_lines)
|
|
.wrap(Wrap { trim: false })
|
|
.render(layout[2], frame.buffer_mut());
|
|
|
|
// --- Overlay dialogs (rendered on top of settings) ---
|
|
|
|
// Status message takes highest priority
|
|
if let Some(ref msg) = app.settings_status_message {
|
|
let border_color = match msg.kind {
|
|
StatusKind::Success => colors.accent(),
|
|
StatusKind::Error => colors.error(),
|
|
};
|
|
let title = match msg.kind {
|
|
StatusKind::Success => " Success ",
|
|
StatusKind::Error => " Error ",
|
|
};
|
|
let dialog_width = 56u16.min(area.width.saturating_sub(4));
|
|
let dialog_height = 6u16;
|
|
let dialog_x = area.x + area.width.saturating_sub(dialog_width) / 2;
|
|
let dialog_y = area.y + area.height.saturating_sub(dialog_height) / 2;
|
|
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
|
|
|
|
frame.render_widget(ratatui::widgets::Clear, dialog_area);
|
|
let dialog = Paragraph::new(vec![
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
format!(" {} ", msg.text),
|
|
Style::default().fg(colors.fg()),
|
|
)),
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
" Press any key",
|
|
Style::default().fg(colors.text_pending()),
|
|
)),
|
|
])
|
|
.wrap(Wrap { trim: false })
|
|
.style(Style::default().bg(colors.bg()))
|
|
.block(
|
|
Block::bordered()
|
|
.title(title)
|
|
.border_style(Style::default().fg(border_color))
|
|
.style(Style::default().bg(colors.bg())),
|
|
);
|
|
frame.render_widget(dialog, dialog_area);
|
|
} else if app.settings_export_conflict {
|
|
let dialog_width = 52u16.min(area.width.saturating_sub(4));
|
|
let dialog_height = 6u16;
|
|
let dialog_x = area.x + area.width.saturating_sub(dialog_width) / 2;
|
|
let dialog_y = area.y + area.height.saturating_sub(dialog_height) / 2;
|
|
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
|
|
|
|
frame.render_widget(ratatui::widgets::Clear, dialog_area);
|
|
let dialog = Paragraph::new(vec![
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
" A file already exists at this path.",
|
|
Style::default().fg(colors.fg()),
|
|
)),
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
" [d] Overwrite [r] Rename [Esc] Cancel",
|
|
Style::default().fg(colors.text_pending()),
|
|
)),
|
|
])
|
|
.style(Style::default().bg(colors.bg()))
|
|
.block(
|
|
Block::bordered()
|
|
.title(" File Exists ")
|
|
.border_style(Style::default().fg(colors.error()))
|
|
.style(Style::default().bg(colors.bg())),
|
|
);
|
|
frame.render_widget(dialog, dialog_area);
|
|
} else if app.settings_confirm_import {
|
|
let dialog_width = 52u16.min(area.width.saturating_sub(4));
|
|
let dialog_height = 7u16;
|
|
let dialog_x = area.x + area.width.saturating_sub(dialog_width) / 2;
|
|
let dialog_y = area.y + area.height.saturating_sub(dialog_height) / 2;
|
|
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
|
|
|
|
frame.render_widget(ratatui::widgets::Clear, dialog_area);
|
|
let dialog = Paragraph::new(vec![
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
" This will erase your current data.",
|
|
Style::default().fg(colors.fg()),
|
|
)),
|
|
Line::from(Span::styled(
|
|
" Export first if you want to keep it.",
|
|
Style::default().fg(colors.text_pending()),
|
|
)),
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
" Proceed? (y/n)",
|
|
Style::default().fg(colors.fg()),
|
|
)),
|
|
])
|
|
.style(Style::default().bg(colors.bg()))
|
|
.block(
|
|
Block::bordered()
|
|
.title(" Confirm Import ")
|
|
.border_style(Style::default().fg(colors.error()))
|
|
.style(Style::default().bg(colors.bg())),
|
|
);
|
|
frame.render_widget(dialog, dialog_area);
|
|
}
|
|
}
|
|
|
|
fn wrapped_line_count(text: &str, width: usize) -> usize {
|
|
if width == 0 {
|
|
return 0;
|
|
}
|
|
let chars = text.chars().count().max(1);
|
|
chars.div_ceil(width)
|
|
}
|
|
|
|
fn pack_hint_lines(hints: &[&str], width: usize) -> Vec<String> {
|
|
if width == 0 || hints.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
|
|
let prefix = " ";
|
|
let separator = " ";
|
|
let mut out: Vec<String> = Vec::new();
|
|
let mut current = prefix.to_string();
|
|
let mut has_hint = false;
|
|
|
|
for hint in hints {
|
|
if hint.is_empty() {
|
|
continue;
|
|
}
|
|
let candidate = if has_hint {
|
|
format!("{current}{separator}{hint}")
|
|
} else {
|
|
format!("{current}{hint}")
|
|
};
|
|
if candidate.chars().count() <= width {
|
|
current = candidate;
|
|
has_hint = true;
|
|
} else {
|
|
if has_hint {
|
|
out.push(current);
|
|
}
|
|
current = format!("{prefix}{hint}");
|
|
has_hint = true;
|
|
}
|
|
}
|
|
|
|
if has_hint {
|
|
out.push(current);
|
|
}
|
|
out
|
|
}
|
|
|
|
fn render_code_language_select(frame: &mut ratatui::Frame, app: &App) {
|
|
let area = frame.area();
|
|
let colors = &app.theme.colors;
|
|
let centered = ui::layout::centered_rect(50, 70, area);
|
|
|
|
let block = Block::bordered()
|
|
.title(" Select Code Language ")
|
|
.border_style(Style::default().fg(colors.accent()))
|
|
.style(Style::default().bg(colors.bg()));
|
|
let inner = block.inner(centered);
|
|
block.render(centered, frame.buffer_mut());
|
|
|
|
let options = code_language_options();
|
|
let cache_dir = &app.config.code_download_dir;
|
|
let footer_hints = [
|
|
"[Up/Down/PgUp/PgDn] Navigate",
|
|
"[Enter] Confirm",
|
|
"[ESC] Back",
|
|
];
|
|
let disabled_notice =
|
|
" Some languages are disabled: enable network downloads in intro/settings.";
|
|
let has_disabled = !app.config.code_downloads_enabled
|
|
&& options
|
|
.iter()
|
|
.any(|(key, _)| is_code_language_disabled(app, key));
|
|
let width = inner.width as usize;
|
|
let hint_lines_vec = pack_hint_lines(&footer_hints, width);
|
|
let hint_lines = hint_lines_vec.len();
|
|
let notice_lines = wrapped_line_count(disabled_notice, width);
|
|
let total_height = inner.height as usize;
|
|
let show_notice = has_disabled && total_height >= hint_lines + notice_lines + 3;
|
|
let desired_footer_height = hint_lines + if show_notice { notice_lines } else { 0 };
|
|
let footer_height = desired_footer_height.min(total_height.saturating_sub(1)) as u16;
|
|
let (list_area, footer_area) = if footer_height > 0 {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Min(1), Constraint::Length(footer_height)])
|
|
.split(inner);
|
|
(chunks[0], Some(chunks[1]))
|
|
} else {
|
|
(inner, None)
|
|
};
|
|
|
|
let viewport_height = (list_area.height as usize).saturating_sub(2).max(1);
|
|
let scroll = app.code_language_scroll;
|
|
|
|
let mut lines: Vec<Line> = Vec::new();
|
|
|
|
// Show scroll indicator at top if scrolled down
|
|
if scroll > 0 {
|
|
lines.push(Line::from(Span::styled(
|
|
format!(" ... {} more above ...", scroll),
|
|
Style::default().fg(colors.text_pending()),
|
|
)));
|
|
} else {
|
|
lines.push(Line::from(""));
|
|
}
|
|
|
|
let visible_end = (scroll + viewport_height).min(options.len());
|
|
|
|
for i in scroll..visible_end {
|
|
let (key, display) = &options[i];
|
|
let is_selected = i == app.code_language_selected;
|
|
let is_current = *key == app.config.code_language;
|
|
let is_disabled = is_code_language_disabled(app, key);
|
|
|
|
let indicator = if is_selected { " > " } else { " " };
|
|
let current_marker = if is_current { " (current)" } else { "" };
|
|
|
|
// Determine availability label
|
|
let availability = if *key == "all" {
|
|
String::new()
|
|
} else if let Some(lang) = language_by_key(key) {
|
|
if lang.has_builtin {
|
|
" (built-in)".to_string()
|
|
} else if is_language_cached(cache_dir, key) {
|
|
" (cached)".to_string()
|
|
} else if is_disabled {
|
|
" (disabled: download required)".to_string()
|
|
} else {
|
|
" (download required)".to_string()
|
|
}
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let name_style = if is_disabled {
|
|
Style::default().fg(colors.text_pending())
|
|
} else if is_selected {
|
|
Style::default()
|
|
.fg(colors.accent())
|
|
.add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::default().fg(colors.fg())
|
|
};
|
|
let status_style = Style::default()
|
|
.fg(colors.text_pending())
|
|
.add_modifier(Modifier::DIM);
|
|
|
|
let mut spans = vec![Span::styled(
|
|
format!("{indicator}{display}{current_marker}"),
|
|
name_style,
|
|
)];
|
|
if !availability.is_empty() {
|
|
spans.push(Span::styled(availability, status_style));
|
|
}
|
|
lines.push(Line::from(spans));
|
|
}
|
|
|
|
// Show scroll indicator at bottom if more items below
|
|
if visible_end < options.len() {
|
|
lines.push(Line::from(Span::styled(
|
|
format!(" ... {} more below ...", options.len() - visible_end),
|
|
Style::default().fg(colors.text_pending()),
|
|
)));
|
|
} else {
|
|
lines.push(Line::from(""));
|
|
}
|
|
|
|
Paragraph::new(lines).render(list_area, frame.buffer_mut());
|
|
|
|
if let Some(footer) = footer_area {
|
|
let mut footer_lines: Vec<Line> = hint_lines_vec
|
|
.iter()
|
|
.map(|line| Line::from(Span::styled(line.clone(), Style::default().fg(colors.text_pending()))))
|
|
.collect();
|
|
if show_notice {
|
|
footer_lines.push(Line::from(Span::styled(
|
|
disabled_notice,
|
|
Style::default().fg(colors.text_pending()),
|
|
)));
|
|
}
|
|
Paragraph::new(footer_lines)
|
|
.wrap(Wrap { trim: false })
|
|
.render(footer, frame.buffer_mut());
|
|
}
|
|
}
|
|
|
|
fn render_passage_book_select(frame: &mut ratatui::Frame, app: &App) {
|
|
let area = frame.area();
|
|
let colors = &app.theme.colors;
|
|
let centered = ui::layout::centered_rect(60, 70, area);
|
|
|
|
let block = Block::bordered()
|
|
.title(" Select Passage Source ")
|
|
.border_style(Style::default().fg(colors.accent()))
|
|
.style(Style::default().bg(colors.bg()));
|
|
let inner = block.inner(centered);
|
|
block.render(centered, frame.buffer_mut());
|
|
|
|
let options = passage_options();
|
|
let footer_hints = ["[Up/Down] Navigate", "[Enter] Confirm", "[ESC] Back"];
|
|
let disabled_notice =
|
|
" Some sources are disabled: enable network downloads in intro/settings.";
|
|
let has_disabled = !app.config.passage_downloads_enabled
|
|
&& options
|
|
.iter()
|
|
.any(|(key, _)| is_passage_option_disabled(app, key));
|
|
let width = inner.width as usize;
|
|
let hint_lines_vec = pack_hint_lines(&footer_hints, width);
|
|
let hint_lines = hint_lines_vec.len();
|
|
let notice_lines = wrapped_line_count(disabled_notice, width);
|
|
let total_height = inner.height as usize;
|
|
let show_notice = has_disabled && total_height >= hint_lines + notice_lines + 3;
|
|
let desired_footer_height = hint_lines + if show_notice { notice_lines } else { 0 };
|
|
let footer_height = desired_footer_height.min(total_height.saturating_sub(1)) as u16;
|
|
let (list_area, footer_area) = if footer_height > 0 {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Min(1), Constraint::Length(footer_height)])
|
|
.split(inner);
|
|
(chunks[0], Some(chunks[1]))
|
|
} else {
|
|
(inner, None)
|
|
};
|
|
|
|
let viewport_height = list_area.height as usize;
|
|
let start = app
|
|
.passage_book_selected
|
|
.saturating_sub(viewport_height.saturating_sub(1));
|
|
let end = (start + viewport_height).min(options.len());
|
|
let mut lines: Vec<Line> = vec![];
|
|
for (i, (key, label)) in options.iter().enumerate().skip(start).take(end - start) {
|
|
let is_selected = i == app.passage_book_selected;
|
|
let is_disabled = is_passage_option_disabled(app, key);
|
|
let indicator = if is_selected { " > " } else { " " };
|
|
let availability = if *key == "all" {
|
|
String::new()
|
|
} else if *key == "builtin" {
|
|
" (built-in)".to_string()
|
|
} else if is_book_cached(&app.config.passage_download_dir, key) {
|
|
" (cached)".to_string()
|
|
} else if is_disabled {
|
|
" (disabled: download required)".to_string()
|
|
} else {
|
|
" (download required)".to_string()
|
|
};
|
|
let name_style = if is_disabled {
|
|
Style::default().fg(colors.text_pending())
|
|
} else if is_selected {
|
|
Style::default()
|
|
.fg(colors.accent())
|
|
.add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::default().fg(colors.fg())
|
|
};
|
|
let status_style = Style::default()
|
|
.fg(colors.text_pending())
|
|
.add_modifier(Modifier::DIM);
|
|
let mut spans = vec![Span::styled(
|
|
format!("{indicator}[{}] {label}", i + 1),
|
|
name_style,
|
|
)];
|
|
if !availability.is_empty() {
|
|
spans.push(Span::styled(availability, status_style));
|
|
}
|
|
lines.push(Line::from(spans));
|
|
}
|
|
|
|
Paragraph::new(lines).render(list_area, frame.buffer_mut());
|
|
|
|
if let Some(footer) = footer_area {
|
|
let mut footer_lines: Vec<Line> = hint_lines_vec
|
|
.iter()
|
|
.map(|line| Line::from(Span::styled(line.clone(), Style::default().fg(colors.text_pending()))))
|
|
.collect();
|
|
if show_notice {
|
|
footer_lines.push(Line::from(Span::styled(
|
|
disabled_notice,
|
|
Style::default().fg(colors.text_pending()),
|
|
)));
|
|
}
|
|
Paragraph::new(footer_lines)
|
|
.wrap(Wrap { trim: false })
|
|
.render(footer, frame.buffer_mut());
|
|
}
|
|
}
|
|
|
|
fn render_passage_intro(frame: &mut ratatui::Frame, app: &App) {
|
|
let area = frame.area();
|
|
let colors = &app.theme.colors;
|
|
let centered = ui::layout::centered_rect(75, 80, area);
|
|
|
|
let block = Block::bordered()
|
|
.title(" Passage Downloads Setup ")
|
|
.border_style(Style::default().fg(colors.accent()))
|
|
.style(Style::default().bg(colors.bg()));
|
|
let inner = block.inner(centered);
|
|
block.render(centered, frame.buffer_mut());
|
|
|
|
let paragraphs_value = if app.passage_intro_paragraph_limit == 0 {
|
|
"whole book".to_string()
|
|
} else {
|
|
app.passage_intro_paragraph_limit.to_string()
|
|
};
|
|
|
|
let fields = vec![
|
|
(
|
|
"Enable network downloads",
|
|
if app.passage_intro_downloads_enabled {
|
|
"On".to_string()
|
|
} else {
|
|
"Off".to_string()
|
|
},
|
|
),
|
|
("Download directory", app.passage_intro_download_dir.clone()),
|
|
("Paragraphs per book (0 = whole)", paragraphs_value),
|
|
("Start passage drill", "Confirm".to_string()),
|
|
];
|
|
|
|
let mut lines = vec![
|
|
Line::from(Span::styled(
|
|
"Configure passage source settings before your first passage drill.",
|
|
Style::default()
|
|
.fg(colors.fg())
|
|
.add_modifier(Modifier::BOLD),
|
|
)),
|
|
Line::from(Span::styled(
|
|
"Downloads are lazy: books are fetched only when first needed.",
|
|
Style::default().fg(colors.text_pending()),
|
|
)),
|
|
Line::from(Span::styled(
|
|
"If you exit without confirming, this dialog will appear again next time.",
|
|
Style::default().fg(colors.text_pending()),
|
|
)),
|
|
Line::from(""),
|
|
];
|
|
|
|
for (i, (label, value)) in fields.iter().enumerate() {
|
|
let is_selected = i == app.passage_intro_selected;
|
|
let indicator = if is_selected { " > " } else { " " };
|
|
let label_style = if is_selected {
|
|
Style::default()
|
|
.fg(colors.accent())
|
|
.add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::default().fg(colors.fg())
|
|
};
|
|
let value_style = if is_selected {
|
|
Style::default().fg(colors.focused_key())
|
|
} else {
|
|
Style::default().fg(colors.text_pending())
|
|
};
|
|
|
|
lines.push(Line::from(Span::styled(
|
|
format!("{indicator}{label}"),
|
|
label_style,
|
|
)));
|
|
if i == 1 {
|
|
lines.push(Line::from(Span::styled(format!(" {value}"), value_style)));
|
|
} else if i == 3 {
|
|
lines.push(Line::from(Span::styled(
|
|
format!(" [{value}]"),
|
|
value_style,
|
|
)));
|
|
} else {
|
|
lines.push(Line::from(Span::styled(
|
|
format!(" < {value} >"),
|
|
value_style,
|
|
)));
|
|
}
|
|
lines.push(Line::from(""));
|
|
}
|
|
|
|
if app.passage_intro_downloading {
|
|
let total_books = app.passage_intro_download_total.max(1);
|
|
let done_books = app.passage_intro_downloaded.min(total_books);
|
|
let total_bytes = app.passage_intro_download_bytes_total;
|
|
let done_bytes = app
|
|
.passage_intro_download_bytes
|
|
.min(total_bytes.max(app.passage_intro_download_bytes));
|
|
let width = 30usize;
|
|
let fill = if total_bytes > 0 {
|
|
((done_bytes as usize).saturating_mul(width)) / (total_bytes as usize)
|
|
} else {
|
|
0
|
|
};
|
|
let bar = format!(
|
|
"{}{}",
|
|
"=".repeat(fill),
|
|
" ".repeat(width.saturating_sub(fill))
|
|
);
|
|
let progress_text = if total_bytes > 0 {
|
|
format!(" Downloading current book: [{bar}] {done_bytes}/{total_bytes} bytes")
|
|
} else {
|
|
format!(" Downloading current book: {done_bytes} bytes")
|
|
};
|
|
lines.push(Line::from(Span::styled(
|
|
progress_text,
|
|
Style::default()
|
|
.fg(colors.accent())
|
|
.add_modifier(Modifier::BOLD),
|
|
)));
|
|
if !app.passage_intro_current_book.is_empty() {
|
|
lines.push(Line::from(Span::styled(
|
|
format!(
|
|
" Current: {} (book {}/{})",
|
|
app.passage_intro_current_book,
|
|
done_books.saturating_add(1).min(total_books),
|
|
total_books
|
|
),
|
|
Style::default().fg(colors.text_pending()),
|
|
)));
|
|
}
|
|
}
|
|
let hint_lines = if app.passage_intro_downloading {
|
|
Vec::new()
|
|
} else {
|
|
pack_hint_lines(
|
|
&[
|
|
"[Up/Down] Navigate",
|
|
"[Left/Right] Adjust",
|
|
"[Type/Backspace] Edit",
|
|
"[Enter] Confirm",
|
|
"[ESC] Cancel",
|
|
],
|
|
inner.width as usize,
|
|
)
|
|
};
|
|
let footer_height = if hint_lines.is_empty() {
|
|
0
|
|
} else {
|
|
(hint_lines.len() + 1) as u16 // add spacer line above hints
|
|
};
|
|
let (content_area, footer_area) = if footer_height > 0 && footer_height < inner.height {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Min(1), Constraint::Length(footer_height)])
|
|
.split(inner);
|
|
(chunks[0], Some(chunks[1]))
|
|
} else {
|
|
(inner, None)
|
|
};
|
|
Paragraph::new(lines).render(content_area, frame.buffer_mut());
|
|
if let Some(footer) = footer_area {
|
|
let mut footer_lines = vec![Line::from("")];
|
|
footer_lines.extend(
|
|
hint_lines
|
|
.into_iter()
|
|
.map(|hint| Line::from(Span::styled(hint, Style::default().fg(colors.text_pending())))),
|
|
);
|
|
Paragraph::new(footer_lines)
|
|
.wrap(Wrap { trim: false })
|
|
.render(footer, frame.buffer_mut());
|
|
}
|
|
}
|
|
|
|
fn render_passage_download_progress(frame: &mut ratatui::Frame, app: &App) {
|
|
let area = frame.area();
|
|
let colors = &app.theme.colors;
|
|
let centered = ui::layout::centered_rect(60, 35, area);
|
|
|
|
let block = Block::bordered()
|
|
.title(" Downloading Passage Source ")
|
|
.border_style(Style::default().fg(colors.accent()))
|
|
.style(Style::default().bg(colors.bg()));
|
|
let inner = block.inner(centered);
|
|
block.render(centered, frame.buffer_mut());
|
|
|
|
let total_bytes = app.passage_intro_download_bytes_total;
|
|
let done_bytes = app
|
|
.passage_intro_download_bytes
|
|
.min(total_bytes.max(app.passage_intro_download_bytes));
|
|
let width = 36usize;
|
|
let fill = if total_bytes > 0 {
|
|
((done_bytes as usize).saturating_mul(width)) / (total_bytes as usize)
|
|
} else {
|
|
0
|
|
};
|
|
let bar = format!(
|
|
"{}{}",
|
|
"=".repeat(fill),
|
|
" ".repeat(width.saturating_sub(fill))
|
|
);
|
|
|
|
let book_name = if app.passage_intro_current_book.is_empty() {
|
|
"Preparing download...".to_string()
|
|
} else {
|
|
app.passage_intro_current_book.clone()
|
|
};
|
|
|
|
let lines = vec![
|
|
Line::from(Span::styled(
|
|
format!(" Book: {book_name}"),
|
|
Style::default()
|
|
.fg(colors.fg())
|
|
.add_modifier(Modifier::BOLD),
|
|
)),
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
if total_bytes > 0 {
|
|
format!(" [{bar}] {done_bytes}/{total_bytes} bytes")
|
|
} else {
|
|
format!(" Downloaded: {done_bytes} bytes")
|
|
},
|
|
Style::default().fg(colors.accent()),
|
|
)),
|
|
];
|
|
|
|
Paragraph::new(lines).render(inner, frame.buffer_mut());
|
|
}
|
|
|
|
fn render_code_intro(frame: &mut ratatui::Frame, app: &App) {
|
|
let area = frame.area();
|
|
let colors = &app.theme.colors;
|
|
let centered = ui::layout::centered_rect(75, 80, area);
|
|
|
|
let block = Block::bordered()
|
|
.title(" Code Downloads Setup ")
|
|
.border_style(Style::default().fg(colors.accent()))
|
|
.style(Style::default().bg(colors.bg()));
|
|
let inner = block.inner(centered);
|
|
block.render(centered, frame.buffer_mut());
|
|
|
|
let snippets_value = if app.code_intro_snippets_per_repo == 0 {
|
|
"unlimited".to_string()
|
|
} else {
|
|
app.code_intro_snippets_per_repo.to_string()
|
|
};
|
|
|
|
let fields = vec![
|
|
(
|
|
"Enable network downloads",
|
|
if app.code_intro_downloads_enabled {
|
|
"On".to_string()
|
|
} else {
|
|
"Off".to_string()
|
|
},
|
|
),
|
|
("Download directory", app.code_intro_download_dir.clone()),
|
|
("Snippets per repo (0 = unlimited)", snippets_value),
|
|
("Start code drill", "Confirm".to_string()),
|
|
];
|
|
|
|
let mut lines = vec![
|
|
Line::from(Span::styled(
|
|
"Configure code source settings before your first code drill.",
|
|
Style::default()
|
|
.fg(colors.fg())
|
|
.add_modifier(Modifier::BOLD),
|
|
)),
|
|
Line::from(Span::styled(
|
|
"Downloads are lazy: code is fetched only when first needed.",
|
|
Style::default().fg(colors.text_pending()),
|
|
)),
|
|
Line::from(Span::styled(
|
|
"If you exit without confirming, this dialog will appear again next time.",
|
|
Style::default().fg(colors.text_pending()),
|
|
)),
|
|
Line::from(""),
|
|
];
|
|
|
|
for (i, (label, value)) in fields.iter().enumerate() {
|
|
let is_selected = i == app.code_intro_selected;
|
|
let indicator = if is_selected { " > " } else { " " };
|
|
let label_style = if is_selected {
|
|
Style::default()
|
|
.fg(colors.accent())
|
|
.add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::default().fg(colors.fg())
|
|
};
|
|
let value_style = if is_selected {
|
|
Style::default().fg(colors.focused_key())
|
|
} else {
|
|
Style::default().fg(colors.text_pending())
|
|
};
|
|
|
|
lines.push(Line::from(Span::styled(
|
|
format!("{indicator}{label}"),
|
|
label_style,
|
|
)));
|
|
if i == 1 {
|
|
lines.push(Line::from(Span::styled(format!(" {value}"), value_style)));
|
|
} else if i == 3 {
|
|
lines.push(Line::from(Span::styled(
|
|
format!(" [{value}]"),
|
|
value_style,
|
|
)));
|
|
} else {
|
|
lines.push(Line::from(Span::styled(
|
|
format!(" < {value} >"),
|
|
value_style,
|
|
)));
|
|
}
|
|
lines.push(Line::from(""));
|
|
}
|
|
|
|
if app.code_intro_downloading {
|
|
let total_repos = app.code_intro_download_total.max(1);
|
|
let done_repos = app.code_intro_downloaded.min(total_repos);
|
|
let total_bytes = app.code_intro_download_bytes_total;
|
|
let done_bytes = app
|
|
.code_intro_download_bytes
|
|
.min(total_bytes.max(app.code_intro_download_bytes));
|
|
let width = 30usize;
|
|
let fill = if total_bytes > 0 {
|
|
((done_bytes as usize).saturating_mul(width)) / (total_bytes as usize)
|
|
} else {
|
|
0
|
|
};
|
|
let bar = format!(
|
|
"{}{}",
|
|
"=".repeat(fill),
|
|
" ".repeat(width.saturating_sub(fill))
|
|
);
|
|
let progress_text = if total_bytes > 0 {
|
|
format!(" Downloading: [{bar}] {done_bytes}/{total_bytes} bytes")
|
|
} else {
|
|
format!(" Downloading: {done_bytes} bytes")
|
|
};
|
|
lines.push(Line::from(Span::styled(
|
|
progress_text,
|
|
Style::default()
|
|
.fg(colors.accent())
|
|
.add_modifier(Modifier::BOLD),
|
|
)));
|
|
if !app.code_intro_current_repo.is_empty() {
|
|
lines.push(Line::from(Span::styled(
|
|
format!(
|
|
" Current: {} (repo {}/{})",
|
|
app.code_intro_current_repo,
|
|
done_repos.saturating_add(1).min(total_repos),
|
|
total_repos
|
|
),
|
|
Style::default().fg(colors.text_pending()),
|
|
)));
|
|
}
|
|
}
|
|
let hint_lines = if app.code_intro_downloading {
|
|
Vec::new()
|
|
} else {
|
|
pack_hint_lines(
|
|
&[
|
|
"[Up/Down] Navigate",
|
|
"[Left/Right] Adjust",
|
|
"[Type/Backspace] Edit",
|
|
"[Enter] Confirm",
|
|
"[ESC] Cancel",
|
|
],
|
|
inner.width as usize,
|
|
)
|
|
};
|
|
let footer_height = if hint_lines.is_empty() {
|
|
0
|
|
} else {
|
|
(hint_lines.len() + 1) as u16 // add spacer line above hints
|
|
};
|
|
let (content_area, footer_area) = if footer_height > 0 && footer_height < inner.height {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Min(1), Constraint::Length(footer_height)])
|
|
.split(inner);
|
|
(chunks[0], Some(chunks[1]))
|
|
} else {
|
|
(inner, None)
|
|
};
|
|
Paragraph::new(lines).render(content_area, frame.buffer_mut());
|
|
if let Some(footer) = footer_area {
|
|
let mut footer_lines = vec![Line::from("")];
|
|
footer_lines.extend(
|
|
hint_lines
|
|
.into_iter()
|
|
.map(|hint| Line::from(Span::styled(hint, Style::default().fg(colors.text_pending())))),
|
|
);
|
|
Paragraph::new(footer_lines)
|
|
.wrap(Wrap { trim: false })
|
|
.render(footer, frame.buffer_mut());
|
|
}
|
|
}
|
|
|
|
fn render_code_download_progress(frame: &mut ratatui::Frame, app: &App) {
|
|
let area = frame.area();
|
|
let colors = &app.theme.colors;
|
|
let centered = ui::layout::centered_rect(60, 35, area);
|
|
|
|
let block = Block::bordered()
|
|
.title(" Downloading Code Source ")
|
|
.border_style(Style::default().fg(colors.accent()))
|
|
.style(Style::default().bg(colors.bg()));
|
|
let inner = block.inner(centered);
|
|
block.render(centered, frame.buffer_mut());
|
|
|
|
let total_bytes = app.code_intro_download_bytes_total;
|
|
let done_bytes = app
|
|
.code_intro_download_bytes
|
|
.min(total_bytes.max(app.code_intro_download_bytes));
|
|
let width = 36usize;
|
|
let fill = if total_bytes > 0 {
|
|
((done_bytes as usize).saturating_mul(width)) / (total_bytes as usize)
|
|
} else {
|
|
0
|
|
};
|
|
let bar = format!(
|
|
"{}{}",
|
|
"=".repeat(fill),
|
|
" ".repeat(width.saturating_sub(fill))
|
|
);
|
|
|
|
let repo_name = if app.code_intro_current_repo.is_empty() {
|
|
"Preparing download...".to_string()
|
|
} else {
|
|
app.code_intro_current_repo.clone()
|
|
};
|
|
|
|
let lines = vec![
|
|
Line::from(Span::styled(
|
|
format!(" Repo: {repo_name}"),
|
|
Style::default()
|
|
.fg(colors.fg())
|
|
.add_modifier(Modifier::BOLD),
|
|
)),
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
if total_bytes > 0 {
|
|
format!(" [{bar}] {done_bytes}/{total_bytes} bytes")
|
|
} else {
|
|
format!(" Downloaded: {done_bytes} bytes")
|
|
},
|
|
Style::default().fg(colors.accent()),
|
|
)),
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
" [ESC] Cancel",
|
|
Style::default().fg(colors.text_pending()),
|
|
)),
|
|
];
|
|
|
|
Paragraph::new(lines).render(inner, frame.buffer_mut());
|
|
}
|
|
|
|
fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) {
|
|
let area = frame.area();
|
|
let centered = skill_tree_popup_rect(area);
|
|
let widget = SkillTreeWidget::new(
|
|
&app.skill_tree,
|
|
&app.ranked_key_stats,
|
|
app.skill_tree_selected,
|
|
app.skill_tree_detail_scroll,
|
|
app.theme,
|
|
);
|
|
frame.render_widget(widget, centered);
|
|
}
|
|
|
|
fn handle_keyboard_explorer_key(app: &mut App, key: KeyEvent) {
|
|
match key.code {
|
|
KeyCode::Esc => app.go_to_menu(),
|
|
KeyCode::Char('q') if app.keyboard_explorer_selected.is_none() => app.go_to_menu(),
|
|
KeyCode::Char(ch) => {
|
|
app.keyboard_explorer_selected = Some(ch);
|
|
app.key_accuracy(ch, false);
|
|
app.key_accuracy(ch, true);
|
|
}
|
|
KeyCode::Tab => {
|
|
app.keyboard_explorer_selected = Some('\t');
|
|
app.key_accuracy('\t', false);
|
|
app.key_accuracy('\t', true);
|
|
}
|
|
KeyCode::Enter => {
|
|
app.keyboard_explorer_selected = Some('\n');
|
|
app.key_accuracy('\n', false);
|
|
app.key_accuracy('\n', true);
|
|
}
|
|
KeyCode::Backspace => {
|
|
app.keyboard_explorer_selected = Some('\x08');
|
|
app.key_accuracy('\x08', false);
|
|
app.key_accuracy('\x08', true);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn render_keyboard_explorer(frame: &mut ratatui::Frame, app: &App) {
|
|
let area = frame.area();
|
|
let colors = &app.theme.colors;
|
|
|
|
let layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(3), // header
|
|
Constraint::Length(8), // keyboard diagram
|
|
Constraint::Min(3), // detail panel
|
|
Constraint::Length(1), // footer
|
|
])
|
|
.split(area);
|
|
|
|
// Header
|
|
let header_lines = vec![
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
" Keyboard Explorer ",
|
|
Style::default()
|
|
.fg(colors.accent())
|
|
.add_modifier(Modifier::BOLD),
|
|
)),
|
|
Line::from(Span::styled(
|
|
"Press any key to see details",
|
|
Style::default().fg(colors.text_pending()),
|
|
)),
|
|
];
|
|
let header = Paragraph::new(header_lines)
|
|
.alignment(ratatui::layout::Alignment::Center);
|
|
frame.render_widget(header, layout[0]);
|
|
|
|
// Keyboard diagram
|
|
let unlocked = app.skill_tree.unlocked_keys(DrillScope::Global);
|
|
let kbd = KeyboardDiagram::new(
|
|
None,
|
|
&unlocked,
|
|
&app.depressed_keys,
|
|
app.theme,
|
|
&app.keyboard_model,
|
|
)
|
|
.selected_key(app.keyboard_explorer_selected)
|
|
.shift_held(app.shift_held)
|
|
.caps_lock(app.caps_lock);
|
|
frame.render_widget(kbd, layout[1]);
|
|
|
|
// Detail panel
|
|
render_keyboard_detail_panel(frame, app, layout[2]);
|
|
|
|
// Footer
|
|
let footer = Paragraph::new(Line::from(vec![Span::styled(
|
|
" [ESC] Back ",
|
|
Style::default().fg(colors.text_pending()),
|
|
)]));
|
|
frame.render_widget(footer, layout[3]);
|
|
}
|
|
|
|
fn render_keyboard_detail_panel(frame: &mut ratatui::Frame, app: &App, area: Rect) {
|
|
let colors = &app.theme.colors;
|
|
|
|
let selected = match app.keyboard_explorer_selected {
|
|
Some(ch) => ch,
|
|
None => {
|
|
let hint = Paragraph::new(Line::from(Span::styled(
|
|
"Press a key to see its details",
|
|
Style::default().fg(colors.text_pending()),
|
|
)))
|
|
.alignment(ratatui::layout::Alignment::Center)
|
|
.block(
|
|
Block::bordered()
|
|
.border_style(Style::default().fg(colors.border()))
|
|
.title(" Key Details "),
|
|
);
|
|
frame.render_widget(hint, area);
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Build display name for title
|
|
let display_name = key_display_name(selected);
|
|
let title = if display_name.is_empty() {
|
|
format!(" Key Details: '{}' ", selected)
|
|
} else {
|
|
format!(" Key Details: {} ", display_name)
|
|
};
|
|
|
|
let block = Block::bordered()
|
|
.border_style(Style::default().fg(colors.border()))
|
|
.title(Span::styled(
|
|
title,
|
|
Style::default()
|
|
.fg(colors.accent())
|
|
.add_modifier(Modifier::BOLD),
|
|
));
|
|
let inner = block.inner(area);
|
|
frame.render_widget(block, area);
|
|
|
|
let finger = app.keyboard_model.finger_for_char(selected);
|
|
let is_shifted = selected.is_uppercase()
|
|
|| matches!(
|
|
selected,
|
|
'!' | '@' | '#' | '$' | '%' | '^' | '&' | '*' | '(' | ')' | '_' | '+'
|
|
| '{' | '}' | '|' | ':' | '"' | '<' | '>' | '?' | '~'
|
|
);
|
|
let shift_guidance = if is_shifted {
|
|
if finger.hand == Hand::Left {
|
|
"Hold Right Shift (right pinky)".to_string()
|
|
} else {
|
|
"Hold Left Shift (left pinky)".to_string()
|
|
}
|
|
} else {
|
|
"No".to_string()
|
|
};
|
|
|
|
let unlocked_keys = app.skill_tree.unlocked_keys(DrillScope::Global);
|
|
let is_unlocked = unlocked_keys.contains(&selected);
|
|
let focus_key = app
|
|
.skill_tree
|
|
.focused_key(DrillScope::Global, &app.ranked_key_stats);
|
|
let in_focus = focus_key == Some(selected);
|
|
|
|
let overall_stat = app.key_stats.get_stat(selected);
|
|
let ranked_stat = app.ranked_key_stats.get_stat(selected);
|
|
let overall_acc = app
|
|
.explorer_accuracy_cache_overall
|
|
.filter(|(key, _, _)| *key == selected);
|
|
let ranked_acc = app
|
|
.explorer_accuracy_cache_ranked
|
|
.filter(|(key, _, _)| *key == selected);
|
|
|
|
let fmt_avg_time = |stat: Option<&crate::engine::key_stats::KeyStat>| -> String {
|
|
if let Some(stat) = stat {
|
|
if stat.sample_count > 0 {
|
|
return format!("{:.0}ms", stat.filtered_time_ms);
|
|
}
|
|
}
|
|
"No data".to_string()
|
|
};
|
|
let fmt_best_time = |stat: Option<&crate::engine::key_stats::KeyStat>| -> String {
|
|
if let Some(stat) = stat {
|
|
if stat.sample_count > 0 {
|
|
let best = if stat.best_time_ms < f64::MAX {
|
|
stat.best_time_ms
|
|
} else {
|
|
stat.filtered_time_ms
|
|
};
|
|
return format!("{best:.0}ms");
|
|
}
|
|
}
|
|
"No data".to_string()
|
|
};
|
|
let fmt_samples = |stat: Option<&crate::engine::key_stats::KeyStat>| -> String {
|
|
stat.map(|s| s.sample_count.to_string())
|
|
.unwrap_or_else(|| "0".to_string())
|
|
};
|
|
let fmt_acc = |entry: Option<(char, usize, usize)>| -> String {
|
|
if let Some((_, correct, total)) = entry {
|
|
if total > 0 {
|
|
let pct = (correct as f64 / total as f64) * 100.0;
|
|
return format!("{:.1}% ({}/{})", pct, correct, total);
|
|
}
|
|
}
|
|
"No data".to_string()
|
|
};
|
|
|
|
let (branch_name, level_name) = if let Some((branch, level, pos)) = find_key_branch(selected) {
|
|
(branch.name.to_string(), format!("{level} (key #{pos})"))
|
|
} else {
|
|
("Unknown".to_string(), "Unknown".to_string())
|
|
};
|
|
|
|
// Ranked-only mastery display (same semantics as skill tree per-key progress)
|
|
let ranked_conf = app.ranked_key_stats.get_confidence(selected).min(1.0);
|
|
let mastery_bar_width = 10usize;
|
|
let filled = (ranked_conf * mastery_bar_width as f64).round() as usize;
|
|
let mastery_bar = format!(
|
|
"{}{}",
|
|
"\u{2588}".repeat(filled),
|
|
"\u{2591}".repeat(mastery_bar_width.saturating_sub(filled))
|
|
);
|
|
let mastery_text = format!("{mastery_bar} {:>3.0}%", ranked_conf * 100.0);
|
|
|
|
let mut left_col: Vec<String> = vec![
|
|
format!("Finger: {}", finger.description()),
|
|
format!("Shift: {shift_guidance}"),
|
|
format!("Overall Avg Time: {}", fmt_avg_time(overall_stat)),
|
|
format!("Overall Best Time: {}", fmt_best_time(overall_stat)),
|
|
format!("Overall Samples: {}", fmt_samples(overall_stat)),
|
|
format!("Overall Accuracy: {}", fmt_acc(overall_acc)),
|
|
];
|
|
|
|
let mut right_col: Vec<String> = vec![
|
|
format!("Branch: {branch_name}"),
|
|
format!("Level: {level_name}"),
|
|
format!("Unlocked: {}", if is_unlocked { "Yes" } else { "No" }),
|
|
format!("In Focus?: {}", if in_focus { "Yes" } else { "No" }),
|
|
];
|
|
if is_unlocked {
|
|
right_col.push(format!("Mastery: {mastery_text}"));
|
|
} else {
|
|
right_col.push("Mastery: Locked".to_string());
|
|
}
|
|
right_col.push(format!("Ranked Avg Time: {}", fmt_avg_time(ranked_stat)));
|
|
right_col.push(format!("Ranked Best Time: {}", fmt_best_time(ranked_stat)));
|
|
right_col.push(format!("Ranked Samples: {}", fmt_samples(ranked_stat)));
|
|
right_col.push(format!("Ranked Accuracy: {}", fmt_acc(ranked_acc)));
|
|
|
|
if left_col.is_empty() {
|
|
left_col.push("No data yet".to_string());
|
|
}
|
|
if right_col.is_empty() {
|
|
right_col.push("No data yet".to_string());
|
|
}
|
|
|
|
let mut lines: Vec<Line> = Vec::new();
|
|
let split_gap = 3usize;
|
|
let left_width = inner.width.saturating_sub(split_gap as u16) as usize / 2;
|
|
let right_width = inner.width as usize - left_width.saturating_sub(0) - split_gap;
|
|
let row_count = left_col.len().max(right_col.len());
|
|
for i in 0..row_count {
|
|
let left = left_col.get(i).map(String::as_str).unwrap_or("");
|
|
let right = right_col.get(i).map(String::as_str).unwrap_or("");
|
|
let mut left_fit: String = left.chars().take(left_width).collect();
|
|
if left_fit.len() < left_width {
|
|
left_fit.push_str(&" ".repeat(left_width - left_fit.len()));
|
|
}
|
|
let right_fit: String = right.chars().take(right_width).collect();
|
|
lines.push(Line::from(vec![
|
|
Span::styled(" ", Style::default().fg(colors.fg())),
|
|
Span::styled(left_fit, Style::default().fg(colors.fg())),
|
|
Span::styled(" | ", Style::default().fg(colors.border())),
|
|
Span::styled(right_fit, Style::default().fg(colors.fg())),
|
|
]));
|
|
}
|
|
|
|
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
|
|
frame.render_widget(paragraph, inner);
|
|
}
|