First one-shot pass

This commit is contained in:
2026-02-10 14:29:23 -05:00
parent 739d79d6a2
commit f65e3d8413
48 changed files with 5409 additions and 2 deletions

View File

@@ -0,0 +1,67 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::symbols;
use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Widget};
use crate::ui::theme::Theme;
pub struct WpmChart<'a> {
pub data: &'a [(f64, f64)],
pub theme: &'a Theme,
}
impl<'a> WpmChart<'a> {
pub fn new(data: &'a [(f64, f64)], theme: &'a Theme) -> Self {
Self { data, theme }
}
}
impl Widget for WpmChart<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
if self.data.is_empty() {
let block = Block::bordered()
.title(" WPM Over Time ")
.border_style(Style::default().fg(colors.border()));
block.render(area, buf);
return;
}
let max_x = self.data.last().map(|(x, _)| *x).unwrap_or(1.0);
let max_y = self
.data
.iter()
.map(|(_, y)| *y)
.fold(0.0f64, f64::max)
.max(10.0);
let dataset = Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(colors.accent()))
.data(self.data);
let chart = Chart::new(vec![dataset])
.block(
Block::bordered()
.title(" WPM Over Time ")
.border_style(Style::default().fg(colors.border())),
)
.x_axis(
Axis::default()
.title("Lesson")
.style(Style::default().fg(colors.text_pending()))
.bounds([0.0, max_x]),
)
.y_axis(
Axis::default()
.title("WPM")
.style(Style::default().fg(colors.text_pending()))
.bounds([0.0, max_y * 1.1]),
);
chart.render(area, buf);
}
}

View File

@@ -0,0 +1,118 @@
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
use crate::session::result::LessonResult;
use crate::ui::theme::Theme;
pub struct Dashboard<'a> {
pub result: &'a LessonResult,
pub theme: &'a Theme,
}
impl<'a> Dashboard<'a> {
pub fn new(result: &'a LessonResult, theme: &'a Theme) -> Self {
Self { result, theme }
}
}
impl Widget for Dashboard<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Lesson Complete ")
.border_style(Style::default().fg(colors.accent()))
.style(Style::default().bg(colors.bg()));
let inner = block.inner(area);
block.render(area, buf);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(2),
])
.split(inner);
let title = Paragraph::new(Line::from(Span::styled(
"Results",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)))
.alignment(Alignment::Center);
title.render(layout[0], buf);
let wpm_text = format!("{:.0} WPM", self.result.wpm);
let cpm_text = format!(" ({:.0} CPM)", self.result.cpm);
let wpm_line = Line::from(vec![
Span::styled(" Speed: ", Style::default().fg(colors.fg())),
Span::styled(
&*wpm_text,
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
),
Span::styled(&*cpm_text, Style::default().fg(colors.text_pending())),
]);
Paragraph::new(wpm_line).render(layout[1], buf);
let acc_color = if self.result.accuracy >= 95.0 {
colors.success()
} else if self.result.accuracy >= 85.0 {
colors.warning()
} else {
colors.error()
};
let acc_text = format!("{:.1}%", self.result.accuracy);
let acc_detail = format!(
" ({}/{} correct)",
self.result.correct, self.result.total_chars
);
let acc_line = Line::from(vec![
Span::styled(" Accuracy: ", Style::default().fg(colors.fg())),
Span::styled(
&*acc_text,
Style::default().fg(acc_color).add_modifier(Modifier::BOLD),
),
Span::styled(&*acc_detail, Style::default().fg(colors.text_pending())),
]);
Paragraph::new(acc_line).render(layout[2], buf);
let time_text = format!("{:.1}s", self.result.elapsed_secs);
let time_line = Line::from(vec![
Span::styled(" Time: ", Style::default().fg(colors.fg())),
Span::styled(&*time_text, Style::default().fg(colors.fg())),
]);
Paragraph::new(time_line).render(layout[3], buf);
let error_text = format!("{}", self.result.incorrect);
let chars_line = Line::from(vec![
Span::styled(" Errors: ", Style::default().fg(colors.fg())),
Span::styled(
&*error_text,
Style::default().fg(if self.result.incorrect == 0 {
colors.success()
} else {
colors.error()
}),
),
]);
Paragraph::new(chars_line).render(layout[4], buf);
let help = Paragraph::new(Line::from(vec![
Span::styled(" [r] Retry ", Style::default().fg(colors.accent())),
Span::styled("[q] Menu ", Style::default().fg(colors.accent())),
Span::styled("[s] Stats", Style::default().fg(colors.accent())),
]));
help.render(layout[6], buf);
}
}

View File

@@ -0,0 +1,86 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::{Block, Widget};
use crate::ui::theme::Theme;
pub struct KeyboardDiagram<'a> {
pub focused_key: Option<char>,
pub unlocked_keys: &'a [char],
pub theme: &'a Theme,
}
impl<'a> KeyboardDiagram<'a> {
pub fn new(
focused_key: Option<char>,
unlocked_keys: &'a [char],
theme: &'a Theme,
) -> Self {
Self {
focused_key,
unlocked_keys,
theme,
}
}
}
const ROWS: &[&[char]] = &[
&['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
&['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
];
impl Widget for KeyboardDiagram<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Keyboard ")
.border_style(Style::default().fg(colors.border()))
.style(Style::default().bg(colors.bg()));
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 3 || inner.width < 20 {
return;
}
let key_width: u16 = 4;
let offsets: &[u16] = &[1, 2, 4];
for (row_idx, row) in ROWS.iter().enumerate() {
let y = inner.y + row_idx as u16;
if y >= inner.y + inner.height {
break;
}
let offset = offsets.get(row_idx).copied().unwrap_or(0);
for (col_idx, &key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_width;
if x + 3 > inner.x + inner.width {
break;
}
let is_unlocked = self.unlocked_keys.contains(&key);
let is_focused = self.focused_key == Some(key);
let style = if is_focused {
Style::default()
.fg(colors.bg())
.bg(colors.focused_key())
} else if is_unlocked {
Style::default().fg(colors.fg()).bg(colors.accent_dim())
} else {
Style::default()
.fg(colors.text_pending())
.bg(colors.bg())
};
let display = format!("[{key}]");
buf.set_string(x, y, &display, style);
}
}
}
}

150
src/ui/components/menu.rs Normal file
View File

@@ -0,0 +1,150 @@
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
use crate::ui::theme::Theme;
pub struct MenuItem {
pub key: String,
pub label: String,
pub description: String,
}
pub struct Menu<'a> {
pub items: Vec<MenuItem>,
pub selected: usize,
pub theme: &'a Theme,
}
impl<'a> Menu<'a> {
pub fn new(theme: &'a Theme) -> Self {
Self {
items: vec![
MenuItem {
key: "1".to_string(),
label: "Adaptive Practice".to_string(),
description: "Phonetic words with adaptive letter unlocking".to_string(),
},
MenuItem {
key: "2".to_string(),
label: "Code Practice".to_string(),
description: "Practice typing code syntax".to_string(),
},
MenuItem {
key: "3".to_string(),
label: "Passage Mode".to_string(),
description: "Type passages from books".to_string(),
},
MenuItem {
key: "s".to_string(),
label: "Statistics".to_string(),
description: "View your typing statistics".to_string(),
},
MenuItem {
key: "c".to_string(),
label: "Settings".to_string(),
description: "Configure keydr".to_string(),
},
],
selected: 0,
theme,
}
}
pub fn next(&mut self) {
self.selected = (self.selected + 1) % self.items.len();
}
pub fn prev(&mut self) {
if self.selected > 0 {
self.selected -= 1;
} else {
self.selected = self.items.len() - 1;
}
}
}
impl Widget for &Menu<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.border_style(Style::default().fg(colors.border()))
.style(Style::default().bg(colors.bg()));
let inner = block.inner(area);
block.render(area, buf);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
let title_lines = vec![
Line::from(""),
Line::from(Span::styled(
"keydr",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"Terminal Typing Tutor",
Style::default().fg(colors.fg()),
)),
Line::from(""),
];
let title = Paragraph::new(title_lines).alignment(Alignment::Center);
title.render(layout[0], buf);
let menu_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
self.items
.iter()
.map(|_| Constraint::Length(3))
.collect::<Vec<_>>(),
)
.split(layout[2]);
for (i, item) in self.items.iter().enumerate() {
let is_selected = i == self.selected;
let indicator = if is_selected { ">" } else { " " };
let label_text = format!(" {indicator} [{key}] {label}", key = item.key, label = item.label);
let desc_text = format!(" {}", item.description);
let lines = vec![
Line::from(Span::styled(
&*label_text,
Style::default()
.fg(if is_selected {
colors.accent()
} else {
colors.fg()
})
.add_modifier(if is_selected {
Modifier::BOLD
} else {
Modifier::empty()
}),
)),
Line::from(Span::styled(
&*desc_text,
Style::default().fg(colors.text_pending()),
)),
];
let p = Paragraph::new(lines);
if i < menu_layout.len() {
p.render(menu_layout[i], buf);
}
}
}
}

8
src/ui/components/mod.rs Normal file
View File

@@ -0,0 +1,8 @@
pub mod chart;
pub mod dashboard;
pub mod keyboard_diagram;
pub mod menu;
pub mod progress_bar;
pub mod stats_dashboard;
pub mod stats_sidebar;
pub mod typing_area;

View File

@@ -0,0 +1,53 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::{Block, Widget};
use crate::ui::theme::Theme;
pub struct ProgressBar<'a> {
pub label: String,
pub ratio: f64,
pub theme: &'a Theme,
}
impl<'a> ProgressBar<'a> {
pub fn new(label: &str, ratio: f64, theme: &'a Theme) -> Self {
Self {
label: label.to_string(),
ratio: ratio.clamp(0.0, 1.0),
theme,
}
}
}
impl Widget for ProgressBar<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(format!(" {} ", self.label))
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
if inner.width == 0 || inner.height == 0 {
return;
}
let filled_width = (self.ratio * inner.width as f64) as u16;
let label = format!("{:.0}%", self.ratio * 100.0);
for x in inner.x..inner.x + inner.width {
let style = if x < inner.x + filled_width {
Style::default().fg(colors.bg()).bg(colors.bar_filled())
} else {
Style::default().fg(colors.fg()).bg(colors.bar_empty())
};
buf[(x, inner.y)].set_style(style);
}
let label_x = inner.x + (inner.width.saturating_sub(label.len() as u16)) / 2;
buf.set_string(label_x, inner.y, &label, Style::default().fg(colors.fg()));
}
}

View File

@@ -0,0 +1,119 @@
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::session::result::LessonResult;
use crate::ui::components::chart::WpmChart;
use crate::ui::theme::Theme;
pub struct StatsDashboard<'a> {
pub history: &'a [LessonResult],
pub theme: &'a Theme,
}
impl<'a> StatsDashboard<'a> {
pub fn new(history: &'a [LessonResult], theme: &'a Theme) -> Self {
Self { history, theme }
}
}
impl Widget for StatsDashboard<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Statistics ")
.border_style(Style::default().fg(colors.accent()))
.style(Style::default().bg(colors.bg()));
let inner = block.inner(area);
block.render(area, buf);
if self.history.is_empty() {
let msg = Paragraph::new(Line::from(Span::styled(
"No lessons completed yet. Start typing!",
Style::default().fg(colors.text_pending()),
)));
msg.render(inner, buf);
return;
}
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(8),
Constraint::Min(10),
Constraint::Length(2),
])
.split(inner);
let avg_wpm =
self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
let best_wpm = self
.history
.iter()
.map(|r| r.wpm)
.fold(0.0f64, f64::max);
let avg_accuracy =
self.history.iter().map(|r| r.accuracy).sum::<f64>() / self.history.len() as f64;
let total_lessons = self.history.len();
let total_str = format!("{total_lessons}");
let avg_wpm_str = format!("{avg_wpm:.0}");
let best_wpm_str = format!("{best_wpm:.0}");
let avg_acc_str = format!("{avg_accuracy:.1}%");
let summary = vec![
Line::from(vec![
Span::styled(" Lessons: ", Style::default().fg(colors.fg())),
Span::styled(
&*total_str,
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" Avg WPM: ", Style::default().fg(colors.fg())),
Span::styled(&*avg_wpm_str, Style::default().fg(colors.accent())),
]),
Line::from(vec![
Span::styled(" Best WPM: ", Style::default().fg(colors.fg())),
Span::styled(
&*best_wpm_str,
Style::default()
.fg(colors.success())
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" Avg Accuracy: ", Style::default().fg(colors.fg())),
Span::styled(
&*avg_acc_str,
Style::default().fg(if avg_accuracy >= 95.0 {
colors.success()
} else {
colors.warning()
}),
),
]),
];
Paragraph::new(summary).render(layout[0], buf);
let chart_data: Vec<(f64, f64)> = self
.history
.iter()
.enumerate()
.map(|(i, r)| (i as f64, r.wpm))
.collect();
WpmChart::new(&chart_data, self.theme).render(layout[1], buf);
let help = Paragraph::new(Line::from(Span::styled(
" [ESC] Back to menu",
Style::default().fg(colors.accent()),
)));
help.render(layout[2], buf);
}
}

View File

@@ -0,0 +1,87 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
use crate::session::lesson::LessonState;
use crate::ui::theme::Theme;
pub struct StatsSidebar<'a> {
lesson: &'a LessonState,
theme: &'a Theme,
}
impl<'a> StatsSidebar<'a> {
pub fn new(lesson: &'a LessonState, theme: &'a Theme) -> Self {
Self { lesson, theme }
}
}
impl Widget for StatsSidebar<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let wpm = self.lesson.wpm();
let accuracy = self.lesson.accuracy();
let progress = self.lesson.progress() * 100.0;
let correct = self.lesson.correct_count();
let incorrect = self.lesson.incorrect_count();
let elapsed = self.lesson.elapsed_secs();
let wpm_str = format!("{wpm:.0}");
let acc_str = format!("{accuracy:.1}%");
let prog_str = format!("{progress:.0}%");
let correct_str = format!("{correct}");
let incorrect_str = format!("{incorrect}");
let elapsed_str = format!("{elapsed:.1}s");
let lines = vec![
Line::from(vec![
Span::styled("WPM: ", Style::default().fg(colors.fg())),
Span::styled(&*wpm_str, Style::default().fg(colors.accent())),
]),
Line::from(""),
Line::from(vec![
Span::styled("Accuracy: ", Style::default().fg(colors.fg())),
Span::styled(
&*acc_str,
Style::default().fg(if accuracy >= 95.0 {
colors.success()
} else if accuracy >= 85.0 {
colors.warning()
} else {
colors.error()
}),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Progress: ", Style::default().fg(colors.fg())),
Span::styled(&*prog_str, Style::default().fg(colors.accent())),
]),
Line::from(""),
Line::from(vec![
Span::styled("Correct: ", Style::default().fg(colors.fg())),
Span::styled(&*correct_str, Style::default().fg(colors.success())),
]),
Line::from(vec![
Span::styled("Errors: ", Style::default().fg(colors.fg())),
Span::styled(&*incorrect_str, Style::default().fg(colors.error())),
]),
Line::from(""),
Line::from(vec![
Span::styled("Time: ", Style::default().fg(colors.fg())),
Span::styled(&*elapsed_str, Style::default().fg(colors.fg())),
]),
];
let block = Block::bordered()
.title(" Stats ")
.border_style(Style::default().fg(colors.border()))
.style(Style::default().bg(colors.bg()));
let paragraph = Paragraph::new(lines).block(block);
paragraph.render(area, buf);
}
}

View File

@@ -0,0 +1,61 @@
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::input::CharStatus;
use crate::session::lesson::LessonState;
use crate::ui::theme::Theme;
pub struct TypingArea<'a> {
lesson: &'a LessonState,
theme: &'a Theme,
}
impl<'a> TypingArea<'a> {
pub fn new(lesson: &'a LessonState, theme: &'a Theme) -> Self {
Self { lesson, theme }
}
}
impl Widget for TypingArea<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let mut spans: Vec<Span> = Vec::new();
for (i, &target_ch) in self.lesson.target.iter().enumerate() {
if i < self.lesson.cursor {
let style = match &self.lesson.input[i] {
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.lesson.input[i] {
CharStatus::Incorrect(actual) => *actual,
_ => target_ch,
};
spans.push(Span::styled(display.to_string(), style));
} else if i == self.lesson.cursor {
let style = Style::default()
.fg(colors.text_cursor_fg())
.bg(colors.text_cursor_bg());
spans.push(Span::styled(target_ch.to_string(), style));
} else {
let style = Style::default().fg(colors.text_pending());
spans.push(Span::styled(target_ch.to_string(), style));
}
}
let line = Line::from(spans);
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 });
paragraph.render(area, buf);
}
}