diff --git a/src/main.rs b/src/main.rs index 5bfc0d1..a98a909 100644 --- a/src/main.rs +++ b/src/main.rs @@ -385,6 +385,7 @@ fn handle_result_key(app: &mut App, key: KeyEvent) { KeyCode::Char('y') => { app.delete_session(); app.history_confirm_delete = false; + app.continue_drill(); } KeyCode::Char('n') | KeyCode::Esc => { app.history_confirm_delete = false; @@ -1584,6 +1585,8 @@ mod review_tests { assert!(!app.history_confirm_delete); assert_eq!(app.drill_history.len(), 1); assert_eq!(app.drill_history[0].timestamp, older.timestamp); + assert_eq!(app.screen, AppScreen::Drill); + assert!(app.drill.is_some()); } #[test] @@ -1598,6 +1601,7 @@ mod review_tests { assert!(!app.history_confirm_delete); assert_eq!(app.drill_history.len(), 2); + assert_eq!(app.screen, AppScreen::DrillResult); } #[test] diff --git a/src/ui/components/keyboard_diagram.rs b/src/ui/components/keyboard_diagram.rs index ffa2868..585f286 100644 --- a/src/ui/components/keyboard_diagram.rs +++ b/src/ui/components/keyboard_diagram.rs @@ -84,6 +84,95 @@ fn brighten_color(color: Color) -> Color { } } +fn color_to_rgb(color: Color) -> (u8, u8, u8) { + match color { + Color::Reset => (0, 0, 0), + Color::Black => (0, 0, 0), + Color::Red => (205, 49, 49), + Color::Green => (13, 188, 121), + Color::Yellow => (229, 229, 16), + Color::Blue => (36, 114, 200), + Color::Magenta => (188, 63, 188), + Color::Cyan => (17, 168, 205), + Color::Gray => (229, 229, 229), + Color::DarkGray => (102, 102, 102), + Color::LightRed => (241, 76, 76), + Color::LightGreen => (35, 209, 139), + Color::LightYellow => (245, 245, 67), + Color::LightBlue => (59, 142, 234), + Color::LightMagenta => (214, 112, 214), + Color::LightCyan => (41, 184, 219), + Color::White => (255, 255, 255), + Color::Rgb(r, g, b) => (r, g, b), + Color::Indexed(i) => { + if i < 16 { + const ANSI16: [(u8, u8, u8); 16] = [ + (0, 0, 0), + (128, 0, 0), + (0, 128, 0), + (128, 128, 0), + (0, 0, 128), + (128, 0, 128), + (0, 128, 128), + (192, 192, 192), + (128, 128, 128), + (255, 0, 0), + (0, 255, 0), + (255, 255, 0), + (0, 0, 255), + (255, 0, 255), + (0, 255, 255), + (255, 255, 255), + ]; + ANSI16[i as usize] + } else if i <= 231 { + let idx = i - 16; + let r = idx / 36; + let g = (idx % 36) / 6; + let b = idx % 6; + let cv = |n: u8| if n == 0 { 0 } else { 55 + n * 40 }; + (cv(r), cv(g), cv(b)) + } else { + let v = 8 + (i - 232) * 10; + (v, v, v) + } + } + } +} + +fn relative_luminance(color: Color) -> f64 { + let (r, g, b) = color_to_rgb(color); + let to_linear = |c: u8| { + let x = c as f64 / 255.0; + if x <= 0.03928 { + x / 12.92 + } else { + ((x + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * to_linear(r) + 0.7152 * to_linear(g) + 0.0722 * to_linear(b) +} + +fn contrast_ratio(a: Color, b: Color) -> f64 { + let la = relative_luminance(a); + let lb = relative_luminance(b); + let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; + (hi + 0.05) / (lo + 0.05) +} + +fn readable_fg(bg: Color, preferred: Color) -> Color { + let mut best = preferred; + let mut best_ratio = contrast_ratio(preferred, bg); + for candidate in [Color::White, Color::Black] { + let ratio = contrast_ratio(candidate, bg); + if ratio > best_ratio { + best = candidate; + best_ratio = ratio; + } + } + best +} + /// Blend a color toward the background at the given ratio (0.0 = full bg, 1.0 = full color). fn blend_toward_bg(color: Color, bg: Color, ratio: f32) -> Color { match (color, bg) { @@ -105,17 +194,17 @@ fn modifier_key_style( colors: &crate::ui::theme::ThemeColors, ) -> Style { if is_depressed { + let bg = brighten_color(colors.accent_dim()); Style::default() - .fg(Color::White) - .bg(brighten_color(colors.accent_dim())) + .fg(readable_fg(bg, colors.fg())) + .bg(bg) .add_modifier(Modifier::BOLD) } else if is_next { let bg = blend_toward_bg(colors.accent(), colors.bg(), 0.35); - Style::default().fg(colors.accent()).bg(bg) + Style::default().fg(readable_fg(bg, colors.accent())).bg(bg) } else if is_selected { - Style::default() - .fg(colors.fg()) - .bg(colors.accent_dim()) + let bg = colors.accent_dim(); + Style::default().fg(readable_fg(bg, colors.fg())).bg(bg) } else { Style::default().fg(colors.fg()).bg(colors.bg()) } @@ -129,17 +218,17 @@ fn key_style( colors: &crate::ui::theme::ThemeColors, ) -> Style { if is_depressed { + let bg = brighten_color(colors.accent_dim()); Style::default() - .fg(Color::White) - .bg(brighten_color(colors.accent_dim())) + .fg(readable_fg(bg, colors.fg())) + .bg(bg) .add_modifier(Modifier::BOLD) } else if is_next { let bg = blend_toward_bg(colors.accent(), colors.bg(), 0.35); - Style::default().fg(colors.accent()).bg(bg) + Style::default().fg(readable_fg(bg, colors.accent())).bg(bg) } else if is_selected { - Style::default() - .fg(colors.fg()) - .bg(colors.accent_dim()) + let bg = colors.accent_dim(); + Style::default().fg(readable_fg(bg, colors.fg())).bg(bg) } else if is_unlocked { Style::default().fg(colors.fg()).bg(colors.bg()) } else { @@ -308,9 +397,10 @@ impl KeyboardDiagram<'_> { 2 => { if offset >= 5 { if self.caps_lock { + let bg = colors.accent_dim(); let style = Style::default() - .fg(colors.warning()) - .bg(colors.accent_dim()); + .fg(readable_fg(bg, colors.warning())) + .bg(bg); buf.set_string(inner.x, y, "[Cap]", style); } else { let style = Style::default().fg(colors.text_pending()).bg(colors.bg()); diff --git a/src/ui/components/stats_dashboard.rs b/src/ui/components/stats_dashboard.rs index 5abb1de..6cb927b 100644 --- a/src/ui/components/stats_dashboard.rs +++ b/src/ui/components/stats_dashboard.rs @@ -448,7 +448,8 @@ impl StatsDashboard<'_> { .fg(colors.accent()) .add_modifier(Modifier::BOLD), ))) - .border_style(Style::default().fg(colors.accent())); + .border_style(Style::default().fg(colors.accent())) + .style(Style::default().bg(colors.bg())); block.render(area, buf); return; } @@ -458,10 +459,11 @@ impl StatsDashboard<'_> { let dataset = Dataset::default() .marker(symbols::Marker::Braille) .graph_type(GraphType::Line) - .style(Style::default().fg(colors.success())) + .style(Style::default().fg(colors.success()).bg(colors.bg())) .data(&data); let chart = Chart::new(vec![dataset]) + .style(Style::default().fg(colors.fg()).bg(colors.bg())) .block( Block::bordered() .title(Line::from(Span::styled( @@ -470,22 +472,32 @@ impl StatsDashboard<'_> { .fg(colors.accent()) .add_modifier(Modifier::BOLD), ))) - .border_style(Style::default().fg(colors.accent())), + .border_style(Style::default().fg(colors.accent())) + .style(Style::default().bg(colors.bg())), ) .x_axis( Axis::default() .title("Drill #") - .style(Style::default().fg(colors.text_pending())) + .style(Style::default().fg(colors.text_pending()).bg(colors.bg())) .bounds([0.0, max_x]), ) .y_axis( Axis::default() .title("Accuracy %") - .style(Style::default().fg(colors.text_pending())) + .style(Style::default().fg(colors.text_pending()).bg(colors.bg())) .labels(vec![ - Span::styled("80", Style::default().fg(colors.text_pending())), - Span::styled("90", Style::default().fg(colors.text_pending())), - Span::styled("100", Style::default().fg(colors.text_pending())), + Span::styled( + "80", + Style::default().fg(colors.text_pending()).bg(colors.bg()), + ), + Span::styled( + "90", + Style::default().fg(colors.text_pending()).bg(colors.bg()), + ), + Span::styled( + "100", + Style::default().fg(colors.text_pending()).bg(colors.bg()), + ), ]) .bounds([80.0, 100.0]), ); diff --git a/src/ui/components/typing_area.rs b/src/ui/components/typing_area.rs index 5b6a078..39db2ea 100644 --- a/src/ui/components/typing_area.rs +++ b/src/ui/components/typing_area.rs @@ -26,6 +26,117 @@ struct RenderToken { is_line_break: bool, } +fn color_to_rgb(color: ratatui::style::Color) -> (u8, u8, u8) { + use ratatui::style::Color; + match color { + Color::Reset => (0, 0, 0), + Color::Black => (0, 0, 0), + Color::Red => (205, 49, 49), + Color::Green => (13, 188, 121), + Color::Yellow => (229, 229, 16), + Color::Blue => (36, 114, 200), + Color::Magenta => (188, 63, 188), + Color::Cyan => (17, 168, 205), + Color::Gray => (229, 229, 229), + Color::DarkGray => (102, 102, 102), + Color::LightRed => (241, 76, 76), + Color::LightGreen => (35, 209, 139), + Color::LightYellow => (245, 245, 67), + Color::LightBlue => (59, 142, 234), + Color::LightMagenta => (214, 112, 214), + Color::LightCyan => (41, 184, 219), + Color::White => (255, 255, 255), + Color::Rgb(r, g, b) => (r, g, b), + Color::Indexed(i) => { + if i < 16 { + const ANSI16: [(u8, u8, u8); 16] = [ + (0, 0, 0), + (128, 0, 0), + (0, 128, 0), + (128, 128, 0), + (0, 0, 128), + (128, 0, 128), + (0, 128, 128), + (192, 192, 192), + (128, 128, 128), + (255, 0, 0), + (0, 255, 0), + (255, 255, 0), + (0, 0, 255), + (255, 0, 255), + (0, 255, 255), + (255, 255, 255), + ]; + ANSI16[i as usize] + } else if i <= 231 { + let idx = i - 16; + let r = idx / 36; + let g = (idx % 36) / 6; + let b = idx % 6; + let cv = |n: u8| if n == 0 { 0 } else { 55 + n * 40 }; + (cv(r), cv(g), cv(b)) + } else { + let v = 8 + (i - 232) * 10; + (v, v, v) + } + } + } +} + +fn relative_luminance(color: ratatui::style::Color) -> f64 { + let (r, g, b) = color_to_rgb(color); + let to_linear = |c: u8| { + let x = c as f64 / 255.0; + if x <= 0.03928 { + x / 12.92 + } else { + ((x + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * to_linear(r) + 0.7152 * to_linear(g) + 0.0722 * to_linear(b) +} + +fn contrast_ratio(a: ratatui::style::Color, b: ratatui::style::Color) -> f64 { + let la = relative_luminance(a); + let lb = relative_luminance(b); + let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; + (hi + 0.05) / (lo + 0.05) +} + +fn choose_cursor_colors(colors: &crate::ui::theme::ThemeColors) -> (ratatui::style::Color, ratatui::style::Color) { + use ratatui::style::Color; + + let base_bg = colors.bg(); + let mut cursor_bg = colors.text_cursor_bg(); + + // Ensure cursor block stands out from the typing area's background. + if contrast_ratio(cursor_bg, base_bg) < 1.8 { + let mut best_bg = cursor_bg; + let mut best_ratio = contrast_ratio(cursor_bg, base_bg); + for candidate in [colors.accent(), colors.focused_key(), colors.warning(), Color::Black, Color::White] { + let ratio = contrast_ratio(candidate, base_bg); + if ratio > best_ratio { + best_bg = candidate; + best_ratio = ratio; + } + } + cursor_bg = best_bg; + } + + // Pick most readable fg on top of chosen cursor background. + let mut cursor_fg = colors.text_cursor_fg(); + let mut best_ratio = contrast_ratio(cursor_fg, cursor_bg); + for candidate in [colors.fg(), colors.bg(), Color::Black, Color::White] { + let ratio = contrast_ratio(candidate, cursor_bg); + if ratio > best_ratio { + cursor_fg = candidate; + best_ratio = ratio; + } + } + + (cursor_fg, cursor_bg) +} + /// Expand target chars into render tokens, handling whitespace display. fn build_render_tokens(target: &[char]) -> Vec { let mut tokens = Vec::new(); @@ -71,6 +182,7 @@ fn build_render_tokens(target: &[char]) -> Vec { impl Widget for TypingArea<'_> { fn render(self, area: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; + let (cursor_fg, cursor_bg) = choose_cursor_colors(colors); let tokens = build_render_tokens(&self.drill.target); // Group tokens into lines, splitting on line_break tokens @@ -90,9 +202,9 @@ impl Widget for TypingArea<'_> { } } else if idx == self.drill.cursor { Style::default() - .fg(colors.text_cursor_fg()) - .bg(colors.text_cursor_bg()) - .add_modifier(Modifier::REVERSED | Modifier::BOLD) + .fg(cursor_fg) + .bg(cursor_bg) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(colors.text_pending()) };