use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, Widget, Wrap}; use crate::session::drill::DrillState; use crate::session::input::CharStatus; use crate::ui::theme::Theme; pub struct TypingArea<'a> { drill: &'a DrillState, theme: &'a Theme, } impl<'a> TypingArea<'a> { pub fn new(drill: &'a DrillState, theme: &'a Theme) -> Self { Self { drill, theme } } } /// A render token maps a single target character to its display representation. struct RenderToken { target_idx: usize, display: String, 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(); let mut col = 0usize; for (i, &ch) in target.iter().enumerate() { match ch { '\n' => { tokens.push(RenderToken { target_idx: i, display: "\u{21b5}".to_string(), // ↵ is_line_break: true, }); col = 0; } '\t' => { let tab_width = 4 - (col % 4); let mut display = String::new(); if tab_width > 1 { for _ in 0..(tab_width - 1) { display.push('\u{2500}'); // ─ } } display.push('\u{21E5}'); // ⇥ tokens.push(RenderToken { target_idx: i, display, is_line_break: false, }); col += tab_width; } _ => { tokens.push(RenderToken { target_idx: i, display: ch.to_string(), is_line_break: false, }); col += 1; } } } tokens } 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 let mut lines: Vec> = vec![Vec::new()]; for token in &tokens { let idx = token.target_idx; let target_ch = self.drill.target[idx]; let style = if idx < self.drill.cursor { match &self.drill.input[idx] { CharStatus::Correct => Style::default().fg(colors.text_correct()), CharStatus::Incorrect(_) => Style::default() .fg(colors.text_incorrect()) .bg(colors.text_incorrect_bg()) .add_modifier(Modifier::UNDERLINED), } } else if idx == self.drill.cursor { Style::default() .fg(cursor_fg) .bg(cursor_bg) .add_modifier(Modifier::BOLD) } else { Style::default().fg(colors.text_pending()) }; // For incorrect chars, show the actual typed char for regular chars, // but always show the token display for whitespace markers let display = if idx < self.drill.cursor { if let CharStatus::Incorrect(actual) = &self.drill.input[idx] { if target_ch == '\n' || target_ch == '\t' { // Show the whitespace marker even when incorrect token.display.clone() } else { actual.to_string() } } else { token.display.clone() } } else if idx == self.drill.cursor && target_ch == ' ' { // Keep an actual space at cursor position so soft-wrap break opportunities // remain stable at word boundaries. " ".to_string() } else { token.display.clone() }; lines.last_mut().unwrap().push(Span::styled(display, style)); if token.is_line_break { lines.push(Vec::new()); } } // Keep cursor visible at end-of-input as an insertion marker. if self.drill.cursor >= self.drill.target.len() { lines.last_mut().unwrap().push(Span::styled( "\u{258f}", Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), )); } let ratatui_lines: Vec = lines.into_iter().map(Line::from).collect(); let block = Block::bordered() .border_style(Style::default().fg(colors.border())) .style(Style::default().bg(colors.bg())); let paragraph = Paragraph::new(ratatui_lines) .block(block) .wrap(Wrap { trim: false }); paragraph.render(area, buf); } } #[cfg(test)] mod tests { use super::*; #[test] fn test_render_tokens_basic() { let target: Vec = "abc".chars().collect(); let tokens = build_render_tokens(&target); assert_eq!(tokens.len(), 3); assert_eq!(tokens[0].display, "a"); assert_eq!(tokens[1].display, "b"); assert_eq!(tokens[2].display, "c"); assert!(!tokens[0].is_line_break); } #[test] fn test_render_tokens_newline() { let target: Vec = "a\nb".chars().collect(); let tokens = build_render_tokens(&target); assert_eq!(tokens.len(), 3); assert_eq!(tokens[1].display, "\u{21b5}"); // ↵ assert!(tokens[1].is_line_break); assert_eq!(tokens[1].target_idx, 1); } #[test] fn test_render_tokens_tab() { let target: Vec = "\tx".chars().collect(); let tokens = build_render_tokens(&target); assert_eq!(tokens.len(), 2); // Tab at col 0: width = 4 - (0 % 4) = 4 => "───⇥" assert_eq!(tokens[0].display, "\u{2500}\u{2500}\u{2500}\u{21E5}"); assert!(!tokens[0].is_line_break); assert_eq!(tokens[0].target_idx, 0); } #[test] fn test_render_tokens_tab_alignment() { // "ab\t" -> col 2, tab_width = 4 - (2 % 4) = 2 => "─⇥" let target: Vec = "ab\t".chars().collect(); let tokens = build_render_tokens(&target); assert_eq!(tokens[2].display, "\u{2500}\u{21E5}"); } #[test] fn test_render_tokens_newline_resets_column() { // "\n\tx" -> after newline, col resets to 0, tab_width = 4 let target: Vec = "\n\tx".chars().collect(); let tokens = build_render_tokens(&target); assert_eq!(tokens.len(), 3); assert!(tokens[0].is_line_break); assert_eq!(tokens[1].display, "\u{2500}\u{2500}\u{2500}\u{21E5}"); } }