diff options
| -rw-r--r-- | crates/core/src/app.rs | 96 | ||||
| -rw-r--r-- | crates/core/src/stats.rs | 26 | ||||
| -rw-r--r-- | crates/core/src/ui.rs | 231 | ||||
| -rw-r--r-- | web/src/App.tsx | 17 | ||||
| -rw-r--r-- | web/src/components/EndScreen.tsx | 43 | ||||
| -rw-r--r-- | web/src/components/MainMenu.tsx | 39 | ||||
| -rw-r--r-- | web/src/components/TypingGame.tsx | 29 | ||||
| -rw-r--r-- | web/src/hooks/useCoreGame.ts | 17 | ||||
| -rw-r--r-- | web/src/styles.css | 65 |
9 files changed, 366 insertions, 197 deletions
diff --git a/crates/core/src/app.rs b/crates/core/src/app.rs index b230bc3..5e2da72 100644 --- a/crates/core/src/app.rs +++ b/crates/core/src/app.rs @@ -24,6 +24,7 @@ pub struct App { pub current_text_index: usize, pub should_exit: bool, pub state: State, + pub wpm_history: Vec<u64>, } impl App { @@ -64,9 +65,59 @@ impl App { current_text_index, should_exit, state, + wpm_history: Vec::new(), }) } + fn handle_backspace_with_rules(&mut self, ctrl: bool) { + if self.input.is_empty() { return; } + let current_text = &self.texts[self.current_text_index].content; + if ctrl { + // Delete to start of current word + let word_start = self.get_current_word_start(); + if word_start < self.input.len() { + self.input.truncate(word_start); + self.update_stats(); + } + return; + } + + // Deleting one character. Only allow crossing into previous word if there are errors before. + let target_pos = self.input.len().saturating_sub(1); + let current_word_start = self.get_current_word_start(); + if target_pos < current_word_start { + if !self.has_errors_before_position(current_text, current_word_start) { + // No errors before; do not allow moving back into previous words + return; + } + } + self.input.pop(); + self.update_stats(); + } + + fn get_current_word_start(&self) -> usize { + let mut word_start = 0; + let mut in_word = false; + for (i, c) in self.input.chars().enumerate() { + if c.is_whitespace() { + if in_word { word_start = i + 1; } + in_word = false; + } else { + in_word = true; + } + } + word_start + } + + fn has_errors_before_position(&self, text: &str, position: usize) -> bool { + let compare_len = self.input.len().min(text.len()); + for (i, (ic, tc)) in self.input.chars().zip(text.chars()).take(compare_len).enumerate() { + if i >= position { break; } + if ic != tc { return true; } + } + false + } + pub fn new_with_config(config: Config) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { #[derive(Deserialize)] struct RawText { category: String, content: String, attribution: String } @@ -97,6 +148,7 @@ impl App { current_text_index, stats: Stats::new(), config, + wpm_history: Vec::new(), }) } @@ -104,6 +156,7 @@ impl App { self.input.clear(); self.stats.reset(); self.current_text_index = self.pick_random_index(); + self.wpm_history.clear(); } fn pick_random_index(&self) -> usize { @@ -162,15 +215,45 @@ impl App { State::TypingGame => { match key.code { crossterm::event::KeyCode::Char(c) => { - if !self.stats.is_running() { - self.stats.start(); + // Handle control-word delete (Ctrl+W) + if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) + && (c == 'w' || c == 'W') + { + self.handle_backspace_with_rules(true); + return; + } + // Don't insert invisible control chars; only insert when no CTRL/ALT (SHIFT ok) + if key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + return; } + if !self.stats.is_running() { self.stats.start(); } + // Record keystroke correctness before mutating input + let was_correct = { + let pos = self.input.len(); + let current_text = &self.texts[self.current_text_index].content; + if pos < current_text.len() { + // Compare with target at this position + current_text.chars().nth(pos).map(|tc| tc == c).unwrap_or(false) + } else { + false // extra chars are considered incorrect + } + }; + self.stats.note_keypress(was_correct); self.input.push(c); self.update_stats(); } crossterm::event::KeyCode::Backspace => { - self.input.pop(); - self.update_stats(); + // Treat Ctrl or Alt modified Backspace as word delete for tmux/screen/terms + let ctrl_or_alt = key.modifiers.intersects( + crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT, + ); + self.handle_backspace_with_rules(ctrl_or_alt); + } + // Some terminals send Ctrl+H instead of Ctrl+Backspace + crossterm::event::KeyCode::Char('h') | crossterm::event::KeyCode::Char('H') + if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) => + { + self.handle_backspace_with_rules(true); } crossterm::event::KeyCode::Esc => { self.state = State::MainMenu; @@ -270,6 +353,11 @@ impl App { pub fn update(&mut self) { if self.state == State::TypingGame { self.update_stats(); + // Sample WPM once per elapsed second to build a compact sparkline + let secs = self.stats.elapsed_time().as_secs() as usize; + while self.wpm_history.len() < secs { + self.wpm_history.push(self.stats.wpm().round() as u64); + } } } }
\ No newline at end of file diff --git a/crates/core/src/stats.rs b/crates/core/src/stats.rs index 59d56c9..264000b 100644 --- a/crates/core/src/stats.rs +++ b/crates/core/src/stats.rs @@ -14,6 +14,10 @@ pub struct Stats { total_words: usize, correct_words: usize, errors: usize, + // Persistent keystroke-level tracking (CLI): + // counts every typed character (excluding control sequences) and how many were incorrect at time of keypress + keystrokes_total: usize, + keystrokes_incorrect: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -81,6 +85,8 @@ impl Stats { total_words: 0, correct_words: 0, errors: 0, + keystrokes_total: 0, + keystrokes_incorrect: 0, } } @@ -96,6 +102,8 @@ impl Stats { self.total_words = 0; self.correct_words = 0; self.errors = 0; + self.keystrokes_total = 0; + self.keystrokes_incorrect = 0; } pub fn start(&mut self) { @@ -147,6 +155,15 @@ impl Stats { self.best_streak = self.best_streak.max(best_streak_local); } + // Record a single keypress for persistent accuracy tracking (CLI only). + // If the typed char at the time of keypress was incorrect, mark it as incorrect permanently. + pub fn note_keypress(&mut self, was_correct: bool) { + self.keystrokes_total = self.keystrokes_total.saturating_add(1); + if !was_correct { + self.keystrokes_incorrect = self.keystrokes_incorrect.saturating_add(1); + } + } + pub fn finish(&mut self) { self.end_time = Some(Instant::now()); } @@ -178,11 +195,14 @@ impl Stats { } pub fn get_accuracy(&self) -> f64 { + // Prefer persistent keystroke accuracy for CLI to avoid resetting to 100% after fixes. + if self.keystrokes_total > 0 { + let correct = (self.keystrokes_total - self.keystrokes_incorrect) as f64; + return (correct / self.keystrokes_total as f64) * 100.0; + } if self.total_chars > 0 { (self.correct_chars as f64 / self.total_chars as f64) * 100.0 - } else { - 0.0 - } + } else { 0.0 } } pub fn get_time_elapsed(&self) -> Duration { diff --git a/crates/core/src/ui.rs b/crates/core/src/ui.rs index f503474..ac18e41 100644 --- a/crates/core/src/ui.rs +++ b/crates/core/src/ui.rs @@ -1,22 +1,13 @@ -#[cfg(feature = "tui")] -use std::io; - -#[cfg(feature = "tui")] use ratatui::{ - backend::Backend, layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, - text::{Span}, - widgets::{Block, Borders, Paragraph, Wrap}, + text::Span, + widgets::{Block, Paragraph, Wrap}, Frame, }; +use ratatui::prelude::{Alignment, Line}; -#[cfg(feature = "tui")] -use crate::app::App; - -#[cfg(feature = "tui")] -use ratatui::prelude::{Line, Alignment}; -use crate::app::State; +use crate::app::{App, State}; pub fn draw(f: &mut Frame, app: &App) { match app.state { @@ -26,61 +17,58 @@ pub fn draw(f: &mut Frame, app: &App) { } } -pub fn draw_main_menu(f: &mut Frame, _app: &App) { +pub fn draw_main_menu(f: &mut Frame, app: &App) { let chunks = Layout::default() .direction(Direction::Vertical) - .margin(2) + .margin(1) .constraints([Constraint::Min(0)]) .split(f.size()); let category_label = { - let cat = _app.selected_category.as_ref().map(String::as_str).unwrap_or("Random"); - format!("Category: {} (←/→ to change)", cat) + let cat = app + .selected_category + .as_ref() + .map(String::as_str) + .unwrap_or("Random"); + format!("Category: {} (\u{2190}/\u{2192} to change)", cat) }; - let main_menu = vec![ - Line::from(Span::styled("Welcome to Typerpunk!", Style::default().add_modifier(Modifier::BOLD))), - Line::from(Span::styled(category_label, Style::default().fg(Color::Cyan))), - Line::from(Span::styled("Press Enter to Start", Style::default())), - Line::from(Span::styled("Press Esc to Quit", Style::default())), + + let mut lines: Vec<Line> = vec![ + Line::from(Span::styled( + "TYPERPUNK", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + Line::from(Span::from("")), + Line::from(Span::styled( + category_label, + Style::default().fg(Color::Cyan), + )), + Line::from(Span::from("")), + Line::from(Span::styled("Start: Enter", Style::default())), + Line::from(Span::styled( + "Change Category: \u{2190} / \u{2192}", + Style::default(), + )), + Line::from(Span::styled("Quit: Esc", Style::default())), ]; f.render_widget( - Paragraph::new(main_menu) + Paragraph::new(lines) .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)), + .block(Block::default()), chunks[0], ); } pub fn draw_typing_game(f: &mut Frame, app: &App) { + let area = f.size(); let chunks = Layout::default() .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Length(3), // Stats - Constraint::Min(0), // Text - ]) - .split(f.size()); - - // Draw stats - let stats = vec![ - Line::from(vec![ - Span::styled(format!("WPM: {:.1}", app.stats.wpm()), Style::default().fg(Color::Yellow)), - Span::styled(" | ", Style::default()), - Span::styled(format!("Time: {:.1}s", app.stats.elapsed_time().as_secs_f64()), Style::default().fg(Color::Cyan)), - Span::styled(" | ", Style::default()), - Span::styled(format!("Accuracy: {:.1}%", app.stats.accuracy()), Style::default().fg(Color::Green)), - ]), - ]; - - f.render_widget( - Paragraph::new(stats) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)), - chunks[0], - ); + .margin(1) + .constraints([Constraint::Min(0)]) + .split(area); - // Draw text + // Build colored text let text_chars: Vec<char> = app.current_text().content.chars().collect(); let input_chars: Vec<char> = app.input.chars().collect(); let mut colored_text: Vec<Span> = Vec::new(); @@ -96,7 +84,6 @@ pub fn draw_typing_game(f: &mut Frame, app: &App) { } else { Style::default().fg(Color::Gray) }; - let span = if i == cursor_pos { Span::styled(c.to_string(), style.add_modifier(Modifier::REVERSED)) } else { @@ -105,83 +92,125 @@ pub fn draw_typing_game(f: &mut Frame, app: &App) { colored_text.push(span); } - // Add any remaining incorrect characters if input_chars.len() > text_chars.len() { for &c in &input_chars[text_chars.len()..] { - colored_text.push(Span::styled( - c.to_string(), - Style::default().fg(Color::Red), - )); + colored_text.push(Span::styled(c.to_string(), Style::default().fg(Color::Red))); } } - let text = vec![ - Line::from(Span::styled( - "Type the following text:", - Style::default().add_modifier(Modifier::BOLD), - )), - Line::from(colored_text), - ]; + let lines = vec![Line::from(Span::from("")), Line::from(colored_text)]; f.render_widget( - Paragraph::new(text) - .alignment(Alignment::Left) - .block(Block::default().borders(Borders::ALL)) + Paragraph::new(lines) + .alignment(Alignment::Center) + .block(Block::default()) .wrap(Wrap { trim: true }), - chunks[1], + chunks[0], ); - // Render attribution beneath the text box - let attribution = app.current_text().source.clone(); - if !attribution.is_empty() { - let area = ratatui::layout::Rect { - x: chunks[1].x, - y: chunks[1].y.saturating_add(chunks[1].height.saturating_sub(2)), - width: chunks[1].width, + // Attribution under text + if !app.current_text().source.is_empty() { + let att_area = ratatui::layout::Rect { + x: chunks[0].x, + y: chunks[0].y.saturating_add(chunks[0].height.saturating_sub(5)), + width: chunks[0].width, height: 2, }; let attribution_line = Line::from(Span::styled( - format!("— {}", attribution), + format!("— {}", app.current_text().source), Style::default().fg(Color::Gray), )); f.render_widget( Paragraph::new(vec![attribution_line]) - .alignment(Alignment::Right) + .alignment(Alignment::Center) .wrap(Wrap { trim: true }), - area, + att_area, ); } -} -pub fn draw_end_screen(f: &mut Frame, app: &App) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([Constraint::Min(0)]) - .split(f.size()); + // Anchored stats: WPM (left), ACC (right), TIME (bottom center) + let wpm_rect = ratatui::layout::Rect { x: area.x + 1, y: area.y + area.height.saturating_sub(3), width: 20, height: 3 }; + let acc_rect = ratatui::layout::Rect { x: area.x + area.width.saturating_sub(21), y: area.y + area.height.saturating_sub(3), width: 20, height: 3 }; + let time_rect = ratatui::layout::Rect { x: area.x + area.width / 2 - 10, y: area.y + area.height.saturating_sub(2), width: 20, height: 2 }; - let end_screen = vec![ - Line::from(Span::styled("Game Over!", Style::default().add_modifier(Modifier::BOLD))), + let wpm_widget = Paragraph::new(vec![ + Line::from(Span::styled("WPM", Style::default().fg(Color::Gray))), Line::from(Span::styled( - format!("Words Per Minute: {:.1}", app.stats.wpm()), - Style::default(), + format!("{:.0}", app.stats.wpm()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), )), + ]) + .alignment(Alignment::Left); + + let acc_widget = Paragraph::new(vec![ + Line::from(Span::styled("ACC", Style::default().fg(Color::Gray))), Line::from(Span::styled( - format!("Time Taken: {:.1} seconds", app.stats.elapsed_time().as_secs_f64()), - Style::default(), + format!("{:.0}%", app.stats.accuracy()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), )), + ]) + .alignment(Alignment::Right); + + let time_widget = Paragraph::new(vec![ + Line::from(Span::styled("TIME", Style::default().fg(Color::Gray))), Line::from(Span::styled( - format!("Accuracy: {:.1}%", app.stats.accuracy()), - Style::default(), + format!("{:.1}", app.stats.elapsed_time().as_secs_f64()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), )), - Line::from(Span::styled("Press Enter to Play Again", Style::default())), - Line::from(Span::styled("Press Esc to Quit", Style::default())), - ]; + ]) + .alignment(Alignment::Center); - f.render_widget( - Paragraph::new(end_screen) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)), - chunks[0], - ); -}
\ No newline at end of file + f.render_widget(wpm_widget, wpm_rect); + f.render_widget(acc_widget, acc_rect); + f.render_widget(time_widget, time_rect); +} + +pub fn draw_end_screen(f: &mut Frame, app: &App) { + let area = f.size(); + // We don't render a central RESULTS section to avoid duplication. + // We only render anchored stats and bottom buttons. + + // Anchored stats at the edges + let wpm_rect = ratatui::layout::Rect { x: area.x + 1, y: area.y + area.height.saturating_sub(6), width: 20, height: 3 }; + let acc_rect = ratatui::layout::Rect { x: area.x + area.width.saturating_sub(21), y: area.y + area.height.saturating_sub(6), width: 20, height: 3 }; + let time_rect = ratatui::layout::Rect { x: area.x + area.width / 2 - 10, y: area.y + area.height.saturating_sub(5), width: 20, height: 2 }; + let buttons_rect = ratatui::layout::Rect { x: area.x + area.width / 2 - 20, y: area.y + area.height.saturating_sub(2), width: 40, height: 2 }; + + let wpm_widget = Paragraph::new(vec![ + Line::from(Span::styled("WPM", Style::default().fg(Color::Gray))), + Line::from(Span::styled( + format!("{:.0}", app.stats.wpm()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Left); + + let acc_widget = Paragraph::new(vec![ + Line::from(Span::styled("ACC", Style::default().fg(Color::Gray))), + Line::from(Span::styled( + format!("{:.0}%", app.stats.accuracy()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Right); + + let time_widget = Paragraph::new(vec![ + Line::from(Span::styled("TIME", Style::default().fg(Color::Gray))), + Line::from(Span::styled( + format!("{:.1}", app.stats.elapsed_time().as_secs_f64()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Center); + + let buttons = Paragraph::new(vec![ + Line::from(Span::styled("Enter: Play Again", Style::default())), + Line::from(Span::styled("Esc: Main Menu", Style::default())), + ]) + .alignment(Alignment::Center); + + f.render_widget(wpm_widget, wpm_rect); + f.render_widget(acc_widget, acc_rect); + f.render_widget(time_widget, time_rect); + f.render_widget(buttons, buttons_rect); +} diff --git a/web/src/App.tsx b/web/src/App.tsx index b34c4b4..9b266d9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -108,7 +108,10 @@ function App() { const { game, resetGame, cleanupGame } = useCoreGame(); const [allTexts, setAllTexts] = useState<TextItem[]>(LOCAL_TEXTS); const [categories, setCategories] = useState<string[]>(uniqueCategories(LOCAL_TEXTS)); - const [selectedCategory, setSelectedCategory] = useState<string>('random'); + const [selectedCategory, setSelectedCategory] = useState<string>(() => { + const saved = localStorage.getItem('typerpunk:last_mode'); + return saved || 'random'; + }); const [gameState, setGameState] = useState<GameState>({ screen: 'main-menu', currentText: '', @@ -140,12 +143,7 @@ function App() { })); const testText = "This is a test sentence for the end screen. It has some text to display and check for errors."; const testUserInput = "This is a test sentance for the end screen. It has sone text to display and check for erors."; - const _testCharTimings = testText.split('').map((char, i) => ({ - time: (i / testText.length) * 60, - isCorrect: char === (testUserInput[i] || ''), - char: char, - index: i - })); + // removed unused _testCharTimings const [lastTest, setLastTest] = useState<{ stats: Stats; wpmHistory: Array<{ time: number; wpm: number; raw: number; isError: boolean }>; text: string; userInput: string; charTimings?: Array<{ time: number; isCorrect: boolean; char: string; index: number }>; keypressHistory?: Array<{ time: number; index: number; isCorrect: boolean }> } | null>(null); // Removed unused Enter key end-screen toggle handler @@ -167,6 +165,10 @@ function App() { })(); }, []); + useEffect(() => { + try { localStorage.setItem('typerpunk:last_mode', selectedCategory); } catch {} + }, [selectedCategory]); + const handleStartGame = async () => { try { // Reset game state first @@ -402,6 +404,7 @@ function App() { categories={categories} selectedCategory={selectedCategory} onSelectCategory={setSelectedCategory} + startLabel={`Start: ${selectedCategory === 'random' ? 'Random' : selectedCategory.charAt(0).toUpperCase() + selectedCategory.slice(1)}`} /> ) : gameState.screen === 'end-screen' ? ( <EndScreen diff --git a/web/src/components/EndScreen.tsx b/web/src/components/EndScreen.tsx index 3c70e95..9b1d7ab 100644 --- a/web/src/components/EndScreen.tsx +++ b/web/src/components/EndScreen.tsx @@ -492,7 +492,7 @@ export const EndScreen: FC<Props> = ({ stats, wpmHistory, onPlayAgain, onMainMen }; // --- Layout --- return ( - <div className="end-screen" style={{ maxWidth: 900, margin: '0 auto', padding: '2rem 1rem', minHeight: '100vh', position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100vh', boxSizing: 'border-box', overflow: 'hidden' }}> + <div className="end-screen" style={{ maxWidth: 900, margin: '0 auto', minHeight: '100vh', position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100vh', boxSizing: 'border-box', overflow: 'hidden' }}> {/* Logo at top, same as TypingGame */} <div className="logo" onClick={onMainMenu}>TyperPunk</div> {/* Main content area, all in one flex column, no fixed elements */} @@ -507,7 +507,7 @@ export const EndScreen: FC<Props> = ({ stats, wpmHistory, onPlayAgain, onMainMen boxSizing: 'border-box', }}> {/* Text */} - <div className="end-screen-text" style={{ margin: '0 auto 1.5rem auto', fontSize: '1.25rem', lineHeight: 1.7, maxWidth: 700, width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center', position: 'relative', background: 'rgba(0,0,0,0.04)', borderRadius: 6, padding: '1rem 1.5rem', textAlign: 'left', wordBreak: 'break-word', height: 'auto' }}> + <div className="end-screen-text" style={{ fontSize: '1.25rem', lineHeight: 1.7, maxWidth: 700, width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center', position: 'relative', background: 'rgba(0,0,0,0.04)', borderRadius: 6, padding: '1rem 1.5rem', textAlign: 'left', wordBreak: 'break-word', height: 'auto' }}> <div className="text-display" style={{ whiteSpace: 'pre-wrap', textAlign: 'left', width: '100%' }}>{renderText()}</div> </div> {/* Desktop: WPM | Graph | ACC */} @@ -540,6 +540,45 @@ export const EndScreen: FC<Props> = ({ stats, wpmHistory, onPlayAgain, onMainMen </div> </div> )} + {/* Transparent theme toggle (no class to avoid inherited styles) */} + <button + onClick={toggleTheme} + style={{ + position: 'fixed', + top: '1rem', + right: '1rem', + background: 'transparent', + border: 'none', + boxShadow: 'none', + outline: 'none', + backdropFilter: 'none', + WebkitBackdropFilter: 'none', + appearance: 'none', + WebkitAppearance: 'none', + MozAppearance: 'none', + padding: 0, + margin: 0, + borderRadius: 0, + }} + > + {theme === Theme.Dark ? ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <circle cx="12" cy="12" r="5"/> + <line x1="12" y1="1" x2="12" y2="3"/> + <line x1="12" y1="21" x2="12" y2="23"/> + <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/> + <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> + <line x1="1" y1="12" x2="3" y2="12"/> + <line x1="21" y1="12" x2="23" y2="12"/> + <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/> + <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/> + </svg> + ) : ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> + </svg> + )} + </button> {/* TIME stat below graph */} <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'center', width: '100%', marginTop: 12 }}>TIME</div> <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center', width: '100%' }}>{stats.time.toFixed(1)}</div> diff --git a/web/src/components/MainMenu.tsx b/web/src/components/MainMenu.tsx index b8daea0..fc80039 100644 --- a/web/src/components/MainMenu.tsx +++ b/web/src/components/MainMenu.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { useTheme } from '../contexts/ThemeContext'; import { Theme } from '../types'; @@ -11,33 +11,28 @@ interface Props { const MainMenu: React.FC<Props> = ({ onStartGame, categories = [], selectedCategory = 'random', onSelectCategory }) => { const { theme, toggleTheme } = useTheme(); + const modes = useMemo(() => ['random', ...categories], [categories]); + const currentIndex = Math.max(0, modes.findIndex(m => (selectedCategory || 'random') === m)); + const currentMode = modes[currentIndex] || 'random'; + const nextMode = modes[(currentIndex + 1) % modes.length] || 'random'; return ( <div className="main-menu"> - <h1>TyperPunk</h1> - <div className="menu-options"> - <div style={{ marginBottom: '0.75rem' }}> - <label htmlFor="category" style={{ marginRight: 8 }}>Category:</label> - <select - id="category" - value={selectedCategory} - onChange={(e) => onSelectCategory && onSelectCategory(e.target.value)} - style={{ padding: '0.4rem 0.6rem' }} - > - <option value="random">Random</option> - {categories.map((c) => ( - <option key={c} value={c}>{c}</option> - ))} - </select> - </div> - <button className="menu-button" onClick={onStartGame}> - Start Typing Test + <h1 style={{ marginTop: '-0.25rem', marginBottom: '0.8rem' }}>TyperPunk</h1> + <div className="menu-options" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.6rem' }}> + <button className="menu-button" onClick={onStartGame} style={{ width: 220, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>Start</button> + <button + className="menu-button" + onClick={() => onSelectCategory && onSelectCategory(nextMode)} + style={{ width: 220, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} + > + {`Mode: ${currentMode === 'random' ? 'Random' : currentMode.charAt(0).toUpperCase() + currentMode.slice(1)}`} </button> - <button className="menu-button" onClick={toggleTheme}> - Toggle {theme === Theme.Dark ? 'Light' : 'Dark'} Mode + <button className="menu-button" onClick={toggleTheme} style={{ width: 220, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> + Toggle Theme </button> </div> - <button onClick={toggleTheme} className="theme-toggle"> + <button onClick={toggleTheme} className="theme-toggle" style={{ background: 'transparent', border: 'none', boxShadow: 'none', backdropFilter: 'none' }}> {theme === Theme.Dark ? ( <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <circle cx="12" cy="12" r="5"/> diff --git a/web/src/components/TypingGame.tsx b/web/src/components/TypingGame.tsx index d815420..7d79c24 100644 --- a/web/src/components/TypingGame.tsx +++ b/web/src/components/TypingGame.tsx @@ -52,7 +52,7 @@ export const TypingGame: React.FC<Props> = React.memo((props: Props): JSX.Elemen const [localInput, setLocalInput] = useState<string>(''); const inputRef = useRef<HTMLInputElement>(null); const [wasmAccuracy, setWasmAccuracy] = useState<number>(100); - const [wasmMistakes, setWasmMistakes] = useState<number>(0); + const [_wasmMistakes, setWasmMistakes] = useState<number>(0); const gameRef = useRef(game); const isInitialized = useRef(false); const lastInputRef = useRef(''); @@ -63,7 +63,7 @@ export const TypingGame: React.FC<Props> = React.memo((props: Props): JSX.Elemen const [finalCharTimings, setFinalCharTimings] = useState<Array<{ time: number; isCorrect: boolean; char: string; index: number }>>([]); // Persistent mistake tracking const [allMistakes, setAllMistakes] = useState<Array<{ time: number; index: number }>>([]); - const [finalAllMistakes, setFinalAllMistakes] = useState<Array<{ time: number; index: number }>>([]); + const [_finalAllMistakes, setFinalAllMistakes] = useState<Array<{ time: number; index: number }>>([]); // Persistent keypress history tracking const [keypressHistory, setKeypressHistory] = useState<Array<{ time: number, index: number, isCorrect: boolean }>>([]); const [finalKeypressHistory, setFinalKeypressHistory] = useState<Array<{ time: number, index: number, isCorrect: boolean }>>([]); @@ -164,7 +164,7 @@ export const TypingGame: React.FC<Props> = React.memo((props: Props): JSX.Elemen // Track error positions for this input let isError = false; if (typeof game.get_stats_and_input === 'function') { - const [wasmInput, accuracy, mistakes] = game.get_stats_and_input(); + const [, , mistakes] = game.get_stats_and_input(); // If mistakes increased, mark as error if (mistakes > 0) isError = true; } @@ -504,7 +504,7 @@ export const TypingGame: React.FC<Props> = React.memo((props: Props): JSX.Elemen /> </div> {attribution && ( - <div style={{ maxWidth: 700, width: '100%', margin: '0 auto 1.5rem auto', textAlign: 'right', color: 'var(--neutral-color)', fontSize: '0.9rem' }}> + <div style={{ maxWidth: 700, width: '100%', margin: '0.5rem auto 1.75rem auto', textAlign: 'center', color: 'var(--neutral-color)', fontSize: '0.95rem' }}> — {attribution} </div> )} @@ -527,7 +527,7 @@ export const TypingGame: React.FC<Props> = React.memo((props: Props): JSX.Elemen </div> {/* Graph center, take all available space with margin for stats */} <div style={{ margin: '0 auto 1.5rem auto', width: '100%', maxWidth: 900, display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: 0 }}> - <div className="graph-container" style={{ flex: '1 1 0', minWidth: 0, width: '100%', maxWidth: '100%', maxHeight: 220, minHeight: 220, height: 220, margin: '0 auto', position: 'relative', background: 'rgba(0,0,0,0.02)', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }} /> + <div className="graph-container" style={{ flex: '1 1 0', minWidth: 0, width: '100%', maxWidth: '100%', maxHeight: 160, minHeight: 160, height: 160, margin: '0 auto', position: 'relative', background: 'rgba(0,0,0,0.02)', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }} /> {/* TIME stat below graph */} <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'center', width: '100%', marginTop: 12 }}>TIME</div> <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center', width: '100%' }}>{stats.time.toFixed(1)}</div> @@ -537,36 +537,37 @@ export const TypingGame: React.FC<Props> = React.memo((props: Props): JSX.Elemen {/* Mobile: Graph at top, then WPM & ACC in a row, then TIME below */} {isMobileScreen && ( <> - <div className="graph-container" style={{ flex: 'none', minWidth: 0, width: '100%', maxWidth: '100%', maxHeight: 220, minHeight: 220, height: 220, margin: '0 auto 0.5rem auto', position: 'relative', background: 'rgba(0,0,0,0.02)', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }} /> + <div className="graph-container" style={{ flex: 'none', minWidth: 0, width: '100%', maxWidth: '100%', maxHeight: 220, minHeight: 220, height: 220, margin: '0 auto 0.35rem auto', position: 'relative', background: 'rgba(0,0,0,0.02)', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }} /> <div className="end-screen-stats" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', alignItems: 'center', width: '100%', maxWidth: 700, - margin: '0.5rem auto 0.2rem auto', - gap: '0.3rem', + margin: '0.35rem auto 0.15rem auto', + padding: '0 0.6rem', + gap: '0.2rem', }}> - {/* WPM (left) */} - <div className="end-screen-stat wpm" style={{ textAlign: 'left', alignItems: 'flex-start', justifyContent: 'center' }}> + {/* WPM (left, close to edge) */} + <div className="end-screen-stat wpm" style={{ textAlign: 'left', alignItems: 'flex-start', justifyContent: 'center', paddingLeft: '0.2rem' }}> <div className="stat-label" style={{ textAlign: 'left', width: '100%' }}>WPM</div> <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'left', width: '100%' }}>{Math.round(stats.wpm)}</div> </div> - {/* ACC (right) */} - <div className="end-screen-stat acc" style={{ textAlign: 'right', alignItems: 'flex-end', justifyContent: 'center' }}> + {/* ACC (right, close to edge) */} + <div className="end-screen-stat acc" style={{ textAlign: 'right', alignItems: 'flex-end', justifyContent: 'center', paddingRight: '0.2rem' }}> <div className="stat-label" style={{ textAlign: 'right', width: '100%' }}>ACC</div> <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'right', width: '100%' }}>{Math.round(wasmAccuracy)}%</div> </div> </div> {/* TIME stat below WPM/ACC */} - <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'center', width: '100%', marginTop: 8 }}>TIME</div> + <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'center', width: '100%', marginTop: 12 }}>TIME</div> <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center', width: '100%' }}>{stats.time.toFixed(1)}</div> </> )} </div> {/* Empty space for future game modes, matches EndScreen */} <div className="future-modes-placeholder" /> - <button onClick={toggleTheme} className="theme-toggle"> + <button onClick={toggleTheme} className="theme-toggle" style={{ background: 'transparent', border: 'none', boxShadow: 'none', backdropFilter: 'none' }}> {theme === Theme.Dark ? ( <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <circle cx="12" cy="12" r="5"/> diff --git a/web/src/hooks/useCoreGame.ts b/web/src/hooks/useCoreGame.ts index 3aa69ec..7e0b287 100644 --- a/web/src/hooks/useCoreGame.ts +++ b/web/src/hooks/useCoreGame.ts @@ -42,14 +42,8 @@ export function useCoreGame() { }, []); const resetGame = async () => { - if (gameRef.current) { - try { - gameRef.current.free(); - } catch (err) { - console.error('Error freeing old game:', err); - } - } - + // Do NOT free() here to avoid freeing while React components may still reference it. + // Create a fresh instance and replace the ref. try { const game = new Game(); gameRef.current = game; @@ -60,14 +54,15 @@ export function useCoreGame() { }; const cleanupGame = () => { - if (gameRef.current) { + const inst = gameRef.current; + if (inst) { try { - gameRef.current.free(); + inst.free(); } catch (err) { console.error('Error cleaning up game:', err); } - gameRef.current = null; } + gameRef.current = null; }; return { diff --git a/web/src/styles.css b/web/src/styles.css index d9130b4..5eb97eb 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -12,6 +12,8 @@ --neutral-color: #646669; --caret-color: #00ff9d; --sub-color: #646669; + --header-offset: 3.5rem; + --app-padding: 1rem; } [data-theme="light"] { @@ -58,9 +60,9 @@ body { display: flex; flex-direction: column; align-items: center; - justify-content: center; + justify-content: flex-start; /* avoid vertical centering that pushes content down */ height: 100vh; - padding: 1rem; + padding: var(--app-padding); overflow: hidden; } @@ -145,7 +147,7 @@ body { max-width: 800px; margin: 0 auto; padding: 1rem 2rem 0 2rem; - margin-top: 2.5rem; + margin-top: var(--header-offset); /* unified: just below header/toggle */ display: flex; justify-content: center; align-items: flex-start; @@ -227,37 +229,40 @@ body { position: fixed; top: 1rem; right: 1rem; - background: rgba(255,255,255,0.08); + background: transparent !important; border: none !important; outline: none !important; - box-shadow: 0 2px 12px 0 rgba(0,0,0,0.12), 0 1.5px 4px 0 rgba(0,0,0,0.10); + box-shadow: none !important; color: var(--primary-color); - padding: 0.7rem 1rem; + padding: 0; font-size: 1.2rem; cursor: pointer; - transition: all 0.2s cubic-bezier(.4,0,.2,1); + transition: color 0.2s ease; display: flex; align-items: center; gap: 0.5rem; z-index: 100; - border-radius: 1.5rem; - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); + border-radius: 0; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; } .theme-toggle:focus, .theme-toggle:active { border: none !important; outline: none !important; - box-shadow: 0 4px 16px 0 rgba(0,0,0,0.18); - background: rgba(255,255,255,0.16); + box-shadow: none !important; + background: transparent !important; } .theme-toggle:hover { color: var(--secondary-color); - background: rgba(255,255,255,0.18); - box-shadow: 0 6px 24px 0 rgba(0,0,0,0.18); - transform: scale(1.06); + background: transparent !important; + box-shadow: none !important; + transform: none; } .theme-toggle svg { @@ -291,7 +296,7 @@ body { .text-container { padding: 1rem; - margin-top: 5rem; + margin-top: var(--header-offset); } .stats-container { @@ -403,7 +408,7 @@ body { } .text-container { padding: 0.5rem 0.5rem 0 0.5rem; - margin-top: 1.5rem; + margin-top: var(--header-offset); min-width: 0; } .text-display { @@ -446,8 +451,8 @@ body { width: 100%; max-width: 800px; margin: 0 auto; - padding: 2rem; - padding-top: 6rem; + padding: 0.25rem; + margin-top: calc(var(--header-offset) - var(--app-padding)); /* match .text-container exactly */ min-height: 100vh; position: relative; } @@ -683,7 +688,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { .end-screen-text { width: 100%; max-width: 800px; - margin: 0 auto 2rem auto; + margin: 0 auto 0.5rem auto; font-family: 'JetBrains Mono', monospace; font-size: 1.2rem; color: var(--text-color); @@ -728,7 +733,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { flex: 1 1 0; min-width: 0; margin: 0; - padding: 2rem 0; + padding: 1rem 0; height: 300px; position: relative; background: rgba(0,0,0,0.02); @@ -880,6 +885,9 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { } @media (max-width: 700px) { + :root { + --header-offset: 2.5rem; + } body, #root, .app, .end-screen { overflow-y: auto !important; overflow-x: hidden !important; @@ -887,7 +895,8 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { .end-screen { min-height: 0 !important; height: auto !important; - padding-top: 1.5rem !important; + padding: 0.5rem 0.75rem !important; + padding-top: var(--header-offset) !important; /* align with game text */ padding-bottom: 0.5rem !important; display: flex !important; flex-direction: column !important; @@ -895,16 +904,6 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { .end-screen-buttons { margin-top: auto !important; margin-bottom: 0 !important; - } - .end-screen-main-content { - flex: 1 0 auto !important; width: 100% !important; - display: flex !important; - flex-direction: column !important; } - .end-screen-buttons { - margin-top: auto !important; - margin-bottom: 0 !important; - width: 100% !important; - } -}
\ No newline at end of file +} |
