Import/export feature for config and data

This commit is contained in:
2026-02-22 07:36:34 +00:00
parent 9cc8a214ad
commit 9deffc3d1d
15 changed files with 1717 additions and 125 deletions

View File

@@ -115,9 +115,14 @@ impl Widget for Dashboard<'_> {
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(
" [c/Enter/Space] Continue ",
Style::default().fg(colors.accent()),
),
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())),
Span::styled("[s] Stats ", Style::default().fg(colors.accent())),
Span::styled("[x] Delete", Style::default().fg(colors.accent())),
]));
help.render(layout[6], buf);
}

View File

@@ -382,13 +382,34 @@ impl KeyboardDiagram<'_> {
}
}
// Compute full keyboard width from rendered rows (including trailing modifier keys),
// so the space bar centers relative to the keyboard, not the container.
let keyboard_width = self
.model
.rows
.iter()
.enumerate()
.map(|(row_idx, row)| {
let offset = offsets.get(row_idx).copied().unwrap_or(0);
let row_end = offset + row.len() as u16 * key_width;
match row_idx {
0 => row_end + 6, // [Bksp]
2 => row_end + 7, // [Enter]
3 => row_end + 6, // [Shft]
_ => row_end,
}
})
.max()
.unwrap_or(0)
.min(inner.width);
// Space bar row (row 4)
let space_y = inner.y + 4;
if space_y < inner.y + inner.height {
let space_name = display::key_display_name(SPACE);
let space_label = format!("[ {space_name} ]");
let space_width = space_label.len() as u16;
let space_x = inner.x + (inner.width.saturating_sub(space_width)) / 2;
let space_x = inner.x + (keyboard_width.saturating_sub(space_width)) / 2;
if space_x + space_width <= inner.x + inner.width {
let is_dep = self.depressed_keys.contains(&SPACE);
let is_next = self.next_key == Some(SPACE);

View File

@@ -966,7 +966,8 @@ impl StatsDashboard<'_> {
if y >= inner.y + inner.height {
break;
}
let label = format!(" {ch} {time:>4.0}ms ");
let key_name = display_key_short_fixed(*ch);
let label = format!(" {key_name} {time:>4.0}ms ");
let label_len = label.len() as u16;
buf.set_string(inner.x, y, &label, Style::default().fg(colors.error()));
let bar_space = inner.width.saturating_sub(label_len) as usize;
@@ -1013,7 +1014,8 @@ impl StatsDashboard<'_> {
if y >= inner.y + inner.height {
break;
}
let label = format!(" {ch} {time:>4.0}ms ");
let key_name = display_key_short_fixed(*ch);
let label = format!(" {key_name} {time:>4.0}ms ");
let label_len = label.len() as u16;
buf.set_string(inner.x, y, &label, Style::default().fg(colors.success()));
let bar_space = inner.width.saturating_sub(label_len) as usize;
@@ -1056,6 +1058,7 @@ impl StatsDashboard<'_> {
all_keys.insert(SPACE);
all_keys.insert(TAB);
all_keys.insert(ENTER);
all_keys.insert(BACKSPACE);
let mut key_accuracies: Vec<(char, f64)> = all_keys
.into_iter()
@@ -1091,7 +1094,8 @@ impl StatsDashboard<'_> {
if y >= inner.y + inner.height {
break;
}
let label = format!(" {ch} {acc:>5.1}% ");
let key_name = display_key_short_fixed(*ch);
let label = format!(" {key_name} {acc:>5.1}% ");
let label_len = label.len() as u16;
let color = if *acc >= 95.0 {
colors.warning()
@@ -1132,6 +1136,7 @@ impl StatsDashboard<'_> {
all_keys.insert(SPACE);
all_keys.insert(TAB);
all_keys.insert(ENTER);
all_keys.insert(BACKSPACE);
let mut key_accuracies: Vec<(char, f64)> = all_keys
.into_iter()
@@ -1166,7 +1171,8 @@ impl StatsDashboard<'_> {
if y >= inner.y + inner.height {
break;
}
let label = format!(" {ch} {acc:>5.1}% ");
let key_name = display_key_short_fixed(*ch);
let label = format!(" {key_name} {acc:>5.1}% ");
let label_len = label.len() as u16;
let color = if *acc >= 98.0 {
colors.success()
@@ -1308,6 +1314,16 @@ fn required_kbd_width(key_width: u16, key_step: u16) -> u16 {
max_offset + 12 * key_step + key_width
}
fn display_key_short_fixed(ch: char) -> String {
let special = display::key_short_label(ch);
let raw = if special.is_empty() {
ch.to_string()
} else {
special.to_string()
};
format!("{raw:<4}")
}
fn compute_streaks(active_days: &BTreeSet<chrono::NaiveDate>) -> (usize, usize) {
if active_days.is_empty() {
return (0, 0);

View File

@@ -12,6 +12,7 @@ pub struct StatsSidebar<'a> {
drill: &'a DrillState,
last_result: Option<&'a DrillResult>,
history: &'a [DrillResult],
target_wpm: u32,
theme: &'a Theme,
}
@@ -20,12 +21,14 @@ impl<'a> StatsSidebar<'a> {
drill: &'a DrillState,
last_result: Option<&'a DrillResult>,
history: &'a [DrillResult],
target_wpm: u32,
theme: &'a Theme,
) -> Self {
Self {
drill,
last_result,
history,
target_wpm,
theme,
}
}
@@ -82,6 +85,13 @@ impl Widget for StatsSidebar<'_> {
Span::styled("WPM: ", Style::default().fg(colors.fg())),
Span::styled(wpm_str, Style::default().fg(colors.accent())),
]),
Line::from(vec![
Span::styled("Target: ", Style::default().fg(colors.fg())),
Span::styled(
format!("{} WPM", self.target_wpm),
Style::default().fg(colors.text_pending()),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Accuracy: ", Style::default().fg(colors.fg())),