Skill tree progression system & whitespace support
This commit is contained in:
@@ -42,13 +42,20 @@ impl Widget for Dashboard<'_> {
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let title = Paragraph::new(Line::from(Span::styled(
|
||||
let mut title_spans = vec![Span::styled(
|
||||
"Results",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.alignment(Alignment::Center);
|
||||
)];
|
||||
if !self.result.ranked {
|
||||
title_spans.push(Span::styled(
|
||||
" (Unranked \u{2014} does not count toward skill tree)",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
));
|
||||
}
|
||||
let title = Paragraph::new(Line::from(title_spans))
|
||||
.alignment(Alignment::Center);
|
||||
title.render(layout[0], buf);
|
||||
|
||||
let wpm_text = format!("{:.0} WPM", self.result.wpm);
|
||||
|
||||
@@ -37,6 +37,11 @@ impl<'a> Menu<'a> {
|
||||
label: "Passage Drill".to_string(),
|
||||
description: "Type passages from books".to_string(),
|
||||
},
|
||||
MenuItem {
|
||||
key: "t".to_string(),
|
||||
label: "Skill Tree".to_string(),
|
||||
description: "View progression branches and launch drills".to_string(),
|
||||
},
|
||||
MenuItem {
|
||||
key: "s".to_string(),
|
||||
label: "Statistics".to_string(),
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod dashboard;
|
||||
pub mod keyboard_diagram;
|
||||
pub mod menu;
|
||||
pub mod progress_bar;
|
||||
pub mod skill_tree;
|
||||
pub mod stats_dashboard;
|
||||
pub mod stats_sidebar;
|
||||
pub mod typing_area;
|
||||
|
||||
354
src/ui/components/skill_tree.rs
Normal file
354
src/ui/components/skill_tree.rs
Normal file
@@ -0,0 +1,354 @@
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||
|
||||
use crate::engine::key_stats::KeyStatsStore;
|
||||
use crate::engine::skill_tree::{
|
||||
BranchId, BranchStatus, DrillScope, SkillTree as SkillTreeEngine,
|
||||
get_branch_definition,
|
||||
};
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
pub struct SkillTreeWidget<'a> {
|
||||
skill_tree: &'a SkillTreeEngine,
|
||||
key_stats: &'a KeyStatsStore,
|
||||
selected: usize,
|
||||
theme: &'a Theme,
|
||||
}
|
||||
|
||||
impl<'a> SkillTreeWidget<'a> {
|
||||
pub fn new(
|
||||
skill_tree: &'a SkillTreeEngine,
|
||||
key_stats: &'a KeyStatsStore,
|
||||
selected: usize,
|
||||
theme: &'a Theme,
|
||||
) -> Self {
|
||||
Self {
|
||||
skill_tree,
|
||||
key_stats,
|
||||
selected,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the list of selectable branch IDs (all non-Lowercase branches).
|
||||
pub fn selectable_branches() -> Vec<BranchId> {
|
||||
vec![
|
||||
BranchId::Capitals,
|
||||
BranchId::Numbers,
|
||||
BranchId::ProsePunctuation,
|
||||
BranchId::Whitespace,
|
||||
BranchId::CodeSymbols,
|
||||
]
|
||||
}
|
||||
|
||||
impl Widget for SkillTreeWidget<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Skill Tree ")
|
||||
.border_style(Style::default().fg(colors.accent()))
|
||||
.style(Style::default().bg(colors.bg()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
// Layout: header (2), branch list (dynamic), separator (1), detail panel (dynamic), footer (2)
|
||||
let branches = selectable_branches();
|
||||
let branch_list_height = 3 + branches.len() as u16 * 2 + 1; // root + separator + 5 branches
|
||||
|
||||
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);
|
||||
|
||||
// --- Branch list ---
|
||||
self.render_branch_list(layout[0], buf, &branches);
|
||||
|
||||
// --- Separator ---
|
||||
let sep = Paragraph::new(Line::from(Span::styled(
|
||||
"\u{2500}".repeat(layout[1].width as usize),
|
||||
Style::default().fg(colors.border()),
|
||||
)));
|
||||
sep.render(layout[1], buf);
|
||||
|
||||
// --- Detail panel for selected branch ---
|
||||
self.render_detail_panel(layout[2], buf, &branches);
|
||||
|
||||
// --- Footer ---
|
||||
let footer_text = if self.selected < branches.len() {
|
||||
let bp = self.skill_tree.branch_progress(branches[self.selected]);
|
||||
if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked {
|
||||
" Complete a-z to unlock branches "
|
||||
} else if bp.status == BranchStatus::Available || bp.status == BranchStatus::InProgress {
|
||||
" [Enter] Start Drill [\u{2191}\u{2193}/jk] Navigate [q] Back "
|
||||
} else {
|
||||
" [\u{2191}\u{2193}/jk] Navigate [q] Back "
|
||||
}
|
||||
} else {
|
||||
" [\u{2191}\u{2193}/jk] Navigate [q] Back "
|
||||
};
|
||||
|
||||
let footer = Paragraph::new(Line::from(Span::styled(
|
||||
footer_text,
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
footer.render(layout[3], buf);
|
||||
}
|
||||
}
|
||||
|
||||
impl SkillTreeWidget<'_> {
|
||||
fn render_branch_list(&self, area: Rect, buf: &mut Buffer, branches: &[BranchId]) {
|
||||
let colors = &self.theme.colors;
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Root: Lowercase a-z
|
||||
let lowercase_bp = self.skill_tree.branch_progress(BranchId::Lowercase);
|
||||
let lowercase_def = get_branch_definition(BranchId::Lowercase);
|
||||
let lowercase_total = lowercase_def.levels.iter().map(|l| l.keys.len()).sum::<usize>();
|
||||
let lowercase_confident = self.skill_tree.branch_confident_keys(BranchId::Lowercase, self.key_stats);
|
||||
|
||||
let (prefix, style) = match lowercase_bp.status {
|
||||
BranchStatus::Complete => (
|
||||
"\u{2605} ",
|
||||
Style::default().fg(colors.text_correct()).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
BranchStatus::InProgress => (
|
||||
"\u{25b6} ",
|
||||
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
_ => (
|
||||
" ",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
};
|
||||
|
||||
let status_text = match lowercase_bp.status {
|
||||
BranchStatus::Complete => "COMPLETE".to_string(),
|
||||
BranchStatus::InProgress => {
|
||||
let unlocked = self.skill_tree.lowercase_unlocked_count();
|
||||
format!("{unlocked}/{lowercase_total}")
|
||||
}
|
||||
_ => "LOCKED".to_string(),
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {prefix}{name}", name = lowercase_def.name),
|
||||
style,
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {status_text} {lowercase_confident}/{lowercase_total} keys"),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
]));
|
||||
|
||||
// Progress bar for lowercase
|
||||
let pct = if lowercase_total > 0 {
|
||||
lowercase_confident as f64 / lowercase_total as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", progress_bar_str(pct, 30)),
|
||||
style,
|
||||
)));
|
||||
|
||||
// Separator
|
||||
lines.push(Line::from(Span::styled(
|
||||
" \u{2500}\u{2500} Branches (unlocked after a-z) \u{2500}\u{2500}",
|
||||
Style::default().fg(colors.border()),
|
||||
)));
|
||||
|
||||
// Branches
|
||||
for (i, &branch_id) in branches.iter().enumerate() {
|
||||
let bp = self.skill_tree.branch_progress(branch_id);
|
||||
let def = get_branch_definition(branch_id);
|
||||
let total_keys = def.levels.iter().map(|l| l.keys.len()).sum::<usize>();
|
||||
let confident_keys = self.skill_tree.branch_confident_keys(branch_id, self.key_stats);
|
||||
let is_selected = i == self.selected;
|
||||
|
||||
let (prefix, style) = match bp.status {
|
||||
BranchStatus::Complete => (
|
||||
"\u{2605} ",
|
||||
if is_selected {
|
||||
Style::default().fg(colors.text_correct()).add_modifier(Modifier::BOLD | Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default().fg(colors.text_correct()).add_modifier(Modifier::BOLD)
|
||||
},
|
||||
),
|
||||
BranchStatus::InProgress => (
|
||||
"\u{25b6} ",
|
||||
if is_selected {
|
||||
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD | Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD)
|
||||
},
|
||||
),
|
||||
BranchStatus::Available => (
|
||||
" ",
|
||||
if is_selected {
|
||||
Style::default().fg(colors.fg()).add_modifier(Modifier::BOLD | Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default().fg(colors.fg())
|
||||
},
|
||||
),
|
||||
BranchStatus::Locked => (
|
||||
" ",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
};
|
||||
|
||||
let status_text = match bp.status {
|
||||
BranchStatus::Complete => format!("COMPLETE {confident_keys}/{total_keys} keys"),
|
||||
BranchStatus::InProgress => format!("Lvl {}/{} {confident_keys}/{total_keys} keys", bp.current_level + 1, def.levels.len()),
|
||||
BranchStatus::Available => format!("Available 0/{total_keys} keys"),
|
||||
BranchStatus::Locked => format!("Locked 0/{total_keys} keys"),
|
||||
};
|
||||
|
||||
let sel_indicator = if is_selected { "> " } else { " " };
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("{sel_indicator}{prefix}{}", def.name), style),
|
||||
Span::styled(format!(" {status_text}"), Style::default().fg(colors.text_pending())),
|
||||
]));
|
||||
|
||||
let pct = if total_keys > 0 {
|
||||
confident_keys as f64 / total_keys as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", progress_bar_str(pct, 30)),
|
||||
style,
|
||||
)));
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines);
|
||||
paragraph.render(area, buf);
|
||||
}
|
||||
|
||||
fn render_detail_panel(&self, area: Rect, buf: &mut Buffer, branches: &[BranchId]) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
if self.selected >= branches.len() {
|
||||
return;
|
||||
}
|
||||
|
||||
let branch_id = branches[self.selected];
|
||||
let bp = self.skill_tree.branch_progress(branch_id);
|
||||
let def = get_branch_definition(branch_id);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Branch title with level info
|
||||
let level_text = match bp.status {
|
||||
BranchStatus::InProgress => format!("Level {}/{}", bp.current_level + 1, def.levels.len()),
|
||||
BranchStatus::Complete => format!("Level {}/{}", def.levels.len(), def.levels.len()),
|
||||
_ => format!("Level 0/{}", def.levels.len()),
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {}", def.name),
|
||||
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {level_text}"),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
]));
|
||||
|
||||
// Per-level key breakdown
|
||||
let focused = self.skill_tree.focused_key(DrillScope::Branch(branch_id), self.key_stats);
|
||||
|
||||
for (level_idx, level) in def.levels.iter().enumerate() {
|
||||
let level_status = if bp.status == BranchStatus::Complete || level_idx < bp.current_level {
|
||||
"complete"
|
||||
} else if bp.status == BranchStatus::InProgress && level_idx == bp.current_level {
|
||||
"in progress"
|
||||
} else {
|
||||
"locked"
|
||||
};
|
||||
|
||||
let mut key_spans: Vec<Span> = Vec::new();
|
||||
key_spans.push(Span::styled(
|
||||
format!(" L{}: ", level_idx + 1),
|
||||
Style::default().fg(colors.fg()),
|
||||
));
|
||||
|
||||
for &key in level.keys {
|
||||
let is_confident = self.key_stats.get_confidence(key) >= 1.0;
|
||||
let is_focused = focused == Some(key);
|
||||
|
||||
let display = if key == '\n' {
|
||||
"\\n".to_string()
|
||||
} else if key == '\t' {
|
||||
"\\t".to_string()
|
||||
} else {
|
||||
key.to_string()
|
||||
};
|
||||
|
||||
let style = if is_focused {
|
||||
Style::default()
|
||||
.fg(colors.bg())
|
||||
.bg(colors.focused_key())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_confident {
|
||||
Style::default().fg(colors.text_correct())
|
||||
} else if level_status == "locked" {
|
||||
Style::default().fg(colors.text_pending())
|
||||
} else {
|
||||
Style::default().fg(colors.fg())
|
||||
};
|
||||
|
||||
key_spans.push(Span::styled(display, style));
|
||||
key_spans.push(Span::raw(" "));
|
||||
}
|
||||
|
||||
key_spans.push(Span::styled(
|
||||
format!(" ({level_status})"),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
));
|
||||
|
||||
lines.push(Line::from(key_spans));
|
||||
}
|
||||
|
||||
// Average confidence
|
||||
let total_keys = def.levels.iter().map(|l| l.keys.len()).sum::<usize>();
|
||||
let avg_conf = if total_keys > 0 {
|
||||
let sum: f64 = def.levels.iter()
|
||||
.flat_map(|l| l.keys.iter())
|
||||
.map(|&ch| self.key_stats.get_confidence(ch).min(1.0))
|
||||
.sum();
|
||||
sum / total_keys as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Avg Confidence: {} {:.0}%", progress_bar_str(avg_conf, 20), avg_conf * 100.0),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
|
||||
let paragraph = Paragraph::new(lines);
|
||||
paragraph.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn progress_bar_str(pct: f64, width: usize) -> String {
|
||||
let filled = (pct * width as f64).round() as usize;
|
||||
let empty = width.saturating_sub(filled);
|
||||
format!(
|
||||
"{}{}",
|
||||
"\u{2588}".repeat(filled),
|
||||
"\u{2591}".repeat(empty),
|
||||
)
|
||||
}
|
||||
@@ -488,7 +488,7 @@ impl StatsDashboard<'_> {
|
||||
table_block.render(layout[0], buf);
|
||||
|
||||
let header = Line::from(vec![Span::styled(
|
||||
" # WPM Raw Acc% Time Date",
|
||||
" # WPM Raw Acc% Time Date Mode",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -523,8 +523,14 @@ impl StatsDashboard<'_> {
|
||||
" "
|
||||
};
|
||||
|
||||
let mode_str = if result.ranked {
|
||||
""
|
||||
} else {
|
||||
" (unranked)"
|
||||
};
|
||||
let row = format!(
|
||||
" {wpm_indicator}{idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str}"
|
||||
" {wpm_indicator}{idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str} {mode}{mode_str}",
|
||||
mode = result.drill_mode,
|
||||
);
|
||||
|
||||
let acc_color = if result.accuracy >= 95.0 {
|
||||
@@ -538,6 +544,9 @@ impl StatsDashboard<'_> {
|
||||
let is_selected = i == self.history_selected;
|
||||
let style = if is_selected {
|
||||
Style::default().fg(acc_color).bg(colors.accent_dim())
|
||||
} else if !result.ranked {
|
||||
// Muted styling for unranked drills
|
||||
Style::default().fg(colors.text_pending())
|
||||
} else {
|
||||
Style::default().fg(acc_color)
|
||||
};
|
||||
|
||||
@@ -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