First one-shot pass
This commit is contained in:
67
src/ui/components/chart.rs
Normal file
67
src/ui/components/chart.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
118
src/ui/components/dashboard.rs
Normal file
118
src/ui/components/dashboard.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
86
src/ui/components/keyboard_diagram.rs
Normal file
86
src/ui/components/keyboard_diagram.rs
Normal 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
150
src/ui/components/menu.rs
Normal 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
8
src/ui/components/mod.rs
Normal 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;
|
||||
53
src/ui/components/progress_bar.rs
Normal file
53
src/ui/components/progress_bar.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
119
src/ui/components/stats_dashboard.rs
Normal file
119
src/ui/components/stats_dashboard.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
87
src/ui/components/stats_sidebar.rs
Normal file
87
src/ui/components/stats_sidebar.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
61
src/ui/components/typing_area.rs
Normal file
61
src/ui/components/typing_area.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user