Prevent tests from writing to user data

This commit is contained in:
2026-02-27 05:39:33 +00:00
parent a088075924
commit da907c0f46
7 changed files with 330 additions and 155 deletions

View File

@@ -5,6 +5,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
use crate::session::result::DrillResult;
use crate::ui::layout::pack_hint_lines;
use crate::ui::theme::Theme;
pub struct Dashboard<'a> {
@@ -38,6 +39,19 @@ impl Widget for Dashboard<'_> {
let inner = block.inner(area);
block.render(area, buf);
let footer_line_count = if self.input_lock_remaining_ms.is_some() {
1u16
} else {
let hints = [
"[c/Enter/Space] Continue",
"[r] Retry",
"[q] Menu",
"[s] Stats",
"[x] Delete",
];
pack_hint_lines(&hints, inner.width as usize).len().max(1) as u16
};
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
@@ -47,7 +61,7 @@ impl Widget for Dashboard<'_> {
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(2),
Constraint::Length(footer_line_count),
])
.split(inner);
@@ -137,16 +151,23 @@ impl Widget for Dashboard<'_> {
),
]))
} else {
Paragraph::new(Line::from(vec![
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("[x] Delete", Style::default().fg(colors.accent())),
]))
let hints = [
"[c/Enter/Space] Continue",
"[r] Retry",
"[q] Menu",
"[s] Stats",
"[x] Delete",
];
let lines: Vec<Line> = pack_hint_lines(&hints, inner.width as usize)
.into_iter()
.map(|line| {
Line::from(Span::styled(
line,
Style::default().fg(colors.accent()),
))
})
.collect();
Paragraph::new(lines)
};
help.render(layout[6], buf);
}

View File

@@ -8,6 +8,7 @@ use crate::engine::key_stats::KeyStatsStore;
use crate::engine::skill_tree::{
BranchId, BranchStatus, DrillScope, SkillTree as SkillTreeEngine, get_branch_definition,
};
use crate::ui::layout::{pack_hint_lines, wrapped_line_count};
use crate::ui::theme::Theme;
pub struct SkillTreeWidget<'a> {
@@ -437,48 +438,3 @@ fn dual_progress_bar_parts(
)
}
fn wrapped_line_count(text: &str, width: usize) -> usize {
if width == 0 {
return 0;
}
let chars = text.chars().count().max(1);
chars.div_ceil(width)
}
fn pack_hint_lines(hints: &[&str], width: usize) -> Vec<String> {
if width == 0 || hints.is_empty() {
return Vec::new();
}
let prefix = " ";
let separator = " ";
let mut out: Vec<String> = Vec::new();
let mut current = prefix.to_string();
let mut has_hint = false;
for hint in hints {
if hint.is_empty() {
continue;
}
let candidate = if has_hint {
format!("{current}{separator}{hint}")
} else {
format!("{current}{hint}")
};
if candidate.chars().count() <= width {
current = candidate;
has_hint = true;
} else {
if has_hint {
out.push(current);
}
current = format!("{prefix}{hint}");
has_hint = true;
}
}
if has_hint {
out.push(current);
}
out
}

View File

@@ -11,6 +11,7 @@ use crate::keyboard::display::{self, BACKSPACE, ENTER, MODIFIER_SENTINELS, SPACE
use crate::keyboard::model::KeyboardModel;
use crate::session::result::DrillResult;
use crate::ui::components::activity_heatmap::ActivityHeatmap;
use crate::ui::layout::pack_hint_lines;
use crate::ui::theme::Theme;
// ---------------------------------------------------------------------------
@@ -106,17 +107,8 @@ impl Widget for StatsDashboard<'_> {
return;
}
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Min(10),
Constraint::Length(2),
])
.split(inner);
// Tab header
let tabs = [
// Tab header — width-aware wrapping
let tab_labels = [
"[1] Dashboard",
"[2] History",
"[3] Activity",
@@ -124,10 +116,20 @@ impl Widget for StatsDashboard<'_> {
"[5] Timing",
"[6] N-grams",
];
let tab_spans: Vec<Span> = tabs
.iter()
.enumerate()
.flat_map(|(i, &label)| {
let tab_separator = " ";
let width = inner.width as usize;
let mut tab_lines: Vec<Line> = Vec::new();
{
let mut current_spans: Vec<Span> = Vec::new();
let mut current_width: usize = 0;
for (i, &label) in tab_labels.iter().enumerate() {
let styled_label = format!(" {label} ");
let item_width = styled_label.chars().count() + tab_separator.len();
if current_width > 0 && current_width + item_width > width {
tab_lines.push(Line::from(current_spans));
current_spans = Vec::new();
current_width = 0;
}
let style = if i == self.active_tab {
Style::default()
.fg(colors.accent())
@@ -135,25 +137,56 @@ impl Widget for StatsDashboard<'_> {
} else {
Style::default().fg(colors.text_pending())
};
vec![Span::styled(format!(" {label} "), style), Span::raw(" ")]
})
.collect();
Paragraph::new(Line::from(tab_spans)).render(layout[0], buf);
current_spans.push(Span::styled(styled_label, style));
current_spans.push(Span::raw(tab_separator));
current_width += item_width;
}
if !current_spans.is_empty() {
tab_lines.push(Line::from(current_spans));
}
}
let tab_line_count = tab_lines.len().max(1) as u16;
// Footer — width-aware wrapping
let footer_hints: Vec<&str> = if self.active_tab == 1 {
vec![
"[ESC] Back",
"[Tab] Next tab",
"[1-6] Switch tab",
"[j/k] Navigate",
"[x] Delete",
]
} else {
vec!["[ESC] Back", "[Tab] Next tab", "[1-6] Switch tab"]
};
let footer_lines_vec = pack_hint_lines(&footer_hints, width);
let footer_line_count = footer_lines_vec.len().max(1) as u16;
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(tab_line_count),
Constraint::Min(10),
Constraint::Length(footer_line_count),
])
.split(inner);
Paragraph::new(tab_lines).render(layout[0], buf);
// Render only one tab at a time so each tab gets full breathing room.
self.render_tab(self.active_tab, layout[1], buf);
// Footer
let footer_text = if self.active_tab == 1 {
" [ESC] Back [Tab] Next tab [1-6] Switch tab [j/k] Navigate [x] Delete"
} else {
" [ESC] Back [Tab] Next tab [1-6] Switch tab"
};
let footer = Paragraph::new(Line::from(Span::styled(
footer_text,
Style::default().fg(colors.accent()),
)));
footer.render(layout[2], buf);
let footer_lines: Vec<Line> = footer_lines_vec
.into_iter()
.map(|line| {
Line::from(Span::styled(
line,
Style::default().fg(colors.accent()),
))
})
.collect();
Paragraph::new(footer_lines).render(layout[2], buf);
// Confirmation dialog overlay
if self.history_confirm_delete && self.active_tab == 1 {
@@ -1591,26 +1624,34 @@ impl StatsDashboard<'_> {
fn render_ngram_summary(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let w = area.width as usize;
let gain_str = match data.latest_trigram_gain {
Some(g) => format!("{:.1}%", g * 100.0),
None => "--".to_string(),
};
// Build segments from most to least important, progressively drop from the right
let scope = format!(" {}", data.scope_label);
let bigrams = format!(" | Bi: {}", data.total_bigrams);
let trigrams = format!(" | Tri: {}", data.total_trigrams);
let hesitation = format!(" | Hes: >{:.0}ms", data.hesitation_threshold_ms);
let gain = format!(" | Gain: {}", gain_str);
let gain_note = if data.latest_trigram_gain.is_none() {
" (computed every 50 drills)"
" (every 50)"
} else {
""
};
let line = format!(
" Scope: {} | Bigrams: {} | Trigrams: {} | Hesitation: >{:.0}ms | Tri-gain: {}{}",
data.scope_label,
data.total_bigrams,
data.total_trigrams,
data.hesitation_threshold_ms,
gain_str,
gain_note,
);
let segments: &[&str] = &[&scope, &bigrams, &trigrams, &hesitation, &gain, gain_note];
let mut line = String::new();
for seg in segments {
if line.len() + seg.len() <= w {
line.push_str(seg);
} else {
break;
}
}
buf.set_string(
area.x,