Skill tree progression system & whitespace support
This commit is contained in:
@@ -19,43 +19,172 @@ impl<'a> TypingArea<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A render token maps a single target character to its display representation.
|
||||
struct RenderToken {
|
||||
target_idx: usize,
|
||||
display: String,
|
||||
is_line_break: bool,
|
||||
}
|
||||
|
||||
/// Expand target chars into render tokens, handling whitespace display.
|
||||
fn build_render_tokens(target: &[char]) -> Vec<RenderToken> {
|
||||
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::from("\u{2192}"); // →
|
||||
for _ in 1..tab_width {
|
||||
display.push('\u{00b7}'); // ·
|
||||
}
|
||||
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 mut spans: Vec<Span> = Vec::new();
|
||||
let tokens = build_render_tokens(&self.drill.target);
|
||||
|
||||
for (i, &target_ch) in self.drill.target.iter().enumerate() {
|
||||
if i < self.drill.cursor {
|
||||
let style = match &self.drill.input[i] {
|
||||
// Group tokens into lines, splitting on line_break tokens
|
||||
let mut lines: Vec<Vec<Span>> = vec![Vec::new()];
|
||||
|
||||
for token in &tokens {
|
||||
let idx = token.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),
|
||||
};
|
||||
let display = match &self.drill.input[i] {
|
||||
CharStatus::Incorrect(actual) => *actual,
|
||||
_ => target_ch,
|
||||
};
|
||||
spans.push(Span::styled(display.to_string(), style));
|
||||
} else if i == self.drill.cursor {
|
||||
let style = Style::default()
|
||||
}
|
||||
} else if idx == self.drill.cursor {
|
||||
Style::default()
|
||||
.fg(colors.text_cursor_fg())
|
||||
.bg(colors.text_cursor_bg());
|
||||
spans.push(Span::styled(target_ch.to_string(), style));
|
||||
.bg(colors.text_cursor_bg())
|
||||
} else {
|
||||
let style = Style::default().fg(colors.text_pending());
|
||||
spans.push(Span::styled(target_ch.to_string(), style));
|
||||
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] {
|
||||
let target_ch = self.drill.target[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 {
|
||||
token.display.clone()
|
||||
};
|
||||
|
||||
lines.last_mut().unwrap().push(Span::styled(display, style));
|
||||
|
||||
if token.is_line_break {
|
||||
lines.push(Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
let line = Line::from(spans);
|
||||
let ratatui_lines: Vec<Line> = 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(line).block(block).wrap(Wrap { trim: false });
|
||||
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<char> = "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<char> = "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<char> = "\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{2192}\u{00b7}\u{00b7}\u{00b7}");
|
||||
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<char> = "ab\t".chars().collect();
|
||||
let tokens = build_render_tokens(&target);
|
||||
assert_eq!(tokens[2].display, "\u{2192}\u{00b7}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_tokens_newline_resets_column() {
|
||||
// "\n\tx" -> after newline, col resets to 0, tab_width = 4
|
||||
let target: Vec<char> = "\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{2192}\u{00b7}\u{00b7}\u{00b7}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user