946 lines
32 KiB
Rust
946 lines
32 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, KeyModifiers, KeyboardEnhancementFlags,
|
|
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};
|
|
|
|
use app::{App, AppScreen, DrillMode};
|
|
use engine::skill_tree::DrillScope;
|
|
use event::{AppEvent, EventHandler};
|
|
use session::result::DrillResult;
|
|
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)
|
|
)
|
|
.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 => {
|
|
// 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 depressed keys and shift state for keyboard diagram
|
|
match (&key.code, key.kind) {
|
|
(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
|
|
}
|
|
(_, 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;
|
|
}
|
|
|
|
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),
|
|
}
|
|
}
|
|
|
|
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') => {
|
|
app.go_to_code_language_select();
|
|
}
|
|
KeyCode::Char('3') => {
|
|
app.drill_mode = DrillMode::Passage;
|
|
app.drill_scope = DrillScope::Global;
|
|
app.start_drill();
|
|
}
|
|
KeyCode::Char('t') => app.go_to_skill_tree(),
|
|
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 => {
|
|
app.go_to_code_language_select();
|
|
}
|
|
2 => {
|
|
app.drill_mode = DrillMode::Passage;
|
|
app.drill_scope = DrillScope::Global;
|
|
app.start_drill();
|
|
}
|
|
3 => app.go_to_skill_tree(),
|
|
4 => app.go_to_stats(),
|
|
5 => 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.drill_mode != DrillMode::Adaptive {
|
|
// Non-adaptive: show result screen for partial drill
|
|
if let Some(ref drill) = app.drill {
|
|
let result = DrillResult::from_drill(
|
|
drill,
|
|
&app.drill_events,
|
|
app.drill_mode.as_str(),
|
|
app.drill_mode.is_ranked(),
|
|
);
|
|
app.last_result = Some(result);
|
|
}
|
|
app.screen = AppScreen::DrillResult;
|
|
} 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) {
|
|
match key.code {
|
|
KeyCode::Char('r') => app.retry_drill(),
|
|
KeyCode::Char('q') | KeyCode::Esc => app.go_to_menu(),
|
|
KeyCode::Char('s') => app.go_to_stats(),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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 < 3 {
|
|
app.settings_selected += 1;
|
|
}
|
|
}
|
|
KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => {
|
|
app.settings_cycle_forward();
|
|
}
|
|
KeyCode::Left | KeyCode::Char('h') => {
|
|
app.settings_cycle_backward();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_code_language_key(app: &mut App, key: KeyEvent) {
|
|
const LANGS: &[&str] = &["rust", "python", "javascript", "go", "all"];
|
|
|
|
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 < LANGS.len() {
|
|
app.code_language_selected += 1;
|
|
}
|
|
}
|
|
KeyCode::Char('1') => {
|
|
app.code_language_selected = 0;
|
|
start_code_drill(app, LANGS);
|
|
}
|
|
KeyCode::Char('2') => {
|
|
app.code_language_selected = 1;
|
|
start_code_drill(app, LANGS);
|
|
}
|
|
KeyCode::Char('3') => {
|
|
app.code_language_selected = 2;
|
|
start_code_drill(app, LANGS);
|
|
}
|
|
KeyCode::Char('4') => {
|
|
app.code_language_selected = 3;
|
|
start_code_drill(app, LANGS);
|
|
}
|
|
KeyCode::Char('5') => {
|
|
app.code_language_selected = 4;
|
|
start_code_drill(app, LANGS);
|
|
}
|
|
KeyCode::Enter => {
|
|
start_code_drill(app, LANGS);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn start_code_drill(app: &mut App, langs: &[&str]) {
|
|
if app.code_language_selected < langs.len() {
|
|
app.config.code_language = langs[app.code_language_selected].to_string();
|
|
let _ = app.config.save();
|
|
app.drill_mode = DrillMode::Code;
|
|
app.drill_scope = DrillScope::Global;
|
|
app.start_drill();
|
|
}
|
|
}
|
|
|
|
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 = ui::layout::centered_rect(70, 90, 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 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);
|
|
|
|
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),
|
|
}
|
|
}
|
|
|
|
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 header_info = format!(
|
|
" Level {} | Score {:.0} | {}/{} keys{}",
|
|
crate::engine::scoring::level_from_score(app.profile.total_score),
|
|
app.profile.total_score,
|
|
app.skill_tree.total_unlocked_count(),
|
|
app.skill_tree.total_unique_keys,
|
|
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 [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.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() {
|
|
5 // 3 rows + 2 border
|
|
} else {
|
|
7 // 4 rows + 2 border + 1 label space
|
|
}
|
|
} 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.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 focused = app.skill_tree.focused_key(app.drill_scope, &app.key_stats);
|
|
let kbd_height = if tier.compact_keyboard() { 5 } else { 7 };
|
|
let _ = kbd_height; // Height managed by constraints
|
|
let kbd = KeyboardDiagram::new(
|
|
focused,
|
|
next_char,
|
|
&unlocked_keys,
|
|
&app.depressed_keys,
|
|
app.theme,
|
|
&app.keyboard_model,
|
|
)
|
|
.compact(tier.compact_keyboard())
|
|
.shift_held(app.shift_held);
|
|
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.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_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);
|
|
}
|
|
}
|
|
|
|
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.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 available_themes = ui::theme::Theme::available_themes();
|
|
let languages_all = ["rust", "python", "javascript", "go", "all"];
|
|
let current_lang = &app.config.code_language;
|
|
|
|
let fields: Vec<(String, String)> = vec![
|
|
(
|
|
"Target WPM".to_string(),
|
|
format!("{}", app.config.target_wpm),
|
|
),
|
|
("Theme".to_string(), app.config.theme.clone()),
|
|
(
|
|
"Word Count".to_string(),
|
|
format!("{}", app.config.word_count),
|
|
),
|
|
("Code Language".to_string(), current_lang.clone()),
|
|
];
|
|
|
|
let layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(2),
|
|
Constraint::Length(fields.len() as u16 * 3),
|
|
Constraint::Min(0),
|
|
Constraint::Length(2),
|
|
])
|
|
.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 field_layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints(
|
|
fields
|
|
.iter()
|
|
.map(|_| Constraint::Length(3))
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
.split(layout[1]);
|
|
|
|
for (i, (label, value)) in fields.iter().enumerate() {
|
|
let is_selected = i == app.settings_selected;
|
|
let indicator = if is_selected { " > " } else { " " };
|
|
|
|
let label_text = format!("{indicator}{label}:");
|
|
let value_text = 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 lines = vec![
|
|
Line::from(Span::styled(label_text, label_style)),
|
|
Line::from(Span::styled(value_text, value_style)),
|
|
];
|
|
Paragraph::new(lines).render(field_layout[i], frame.buffer_mut());
|
|
}
|
|
|
|
let _ = (available_themes, languages_all);
|
|
|
|
let footer = Paragraph::new(Line::from(Span::styled(
|
|
" [ESC] Save & back [Enter/arrows] Change value",
|
|
Style::default().fg(colors.accent()),
|
|
)));
|
|
footer.render(layout[3], frame.buffer_mut());
|
|
}
|
|
|
|
fn render_code_language_select(frame: &mut ratatui::Frame, app: &App) {
|
|
let area = frame.area();
|
|
let colors = &app.theme.colors;
|
|
let centered = ui::layout::centered_rect(40, 50, 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 langs = ["Rust", "Python", "JavaScript", "Go", "All (random)"];
|
|
let lang_keys = ["rust", "python", "javascript", "go", "all"];
|
|
|
|
let mut lines: Vec<Line> = Vec::new();
|
|
lines.push(Line::from(""));
|
|
|
|
for (i, &lang) in langs.iter().enumerate() {
|
|
let is_selected = i == app.code_language_selected;
|
|
let is_current = lang_keys[i] == app.config.code_language;
|
|
|
|
let indicator = if is_selected { " > " } else { " " };
|
|
let current_marker = if is_current { " (current)" } else { "" };
|
|
|
|
let style = if is_selected {
|
|
Style::default()
|
|
.fg(colors.accent())
|
|
.add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::default().fg(colors.fg())
|
|
};
|
|
|
|
lines.push(Line::from(Span::styled(
|
|
format!("{indicator}[{}] {lang}{current_marker}", i + 1),
|
|
style,
|
|
)));
|
|
}
|
|
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from(Span::styled(
|
|
" [1-5] Select [Enter] Confirm [ESC] Back",
|
|
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 = ui::layout::centered_rect(70, 90, area);
|
|
let widget = SkillTreeWidget::new(
|
|
&app.skill_tree,
|
|
&app.key_stats,
|
|
app.skill_tree_selected,
|
|
app.skill_tree_detail_scroll,
|
|
app.theme,
|
|
);
|
|
frame.render_widget(widget, centered);
|
|
}
|