N-gram metrics overhaul & UI improvements

This commit is contained in:
2026-02-26 01:26:25 -05:00
parent e7f57dd497
commit 54ddebf054
23 changed files with 3812 additions and 1008 deletions

View File

@@ -10,11 +10,20 @@ use crate::ui::theme::Theme;
pub struct Dashboard<'a> {
pub result: &'a DrillResult,
pub theme: &'a Theme,
pub input_lock_remaining_ms: Option<u64>,
}
impl<'a> Dashboard<'a> {
pub fn new(result: &'a DrillResult, theme: &'a Theme) -> Self {
Self { result, theme }
pub fn new(
result: &'a DrillResult,
theme: &'a Theme,
input_lock_remaining_ms: Option<u64>,
) -> Self {
Self {
result,
theme,
input_lock_remaining_ms,
}
}
}
@@ -114,16 +123,31 @@ impl Widget for Dashboard<'_> {
]);
Paragraph::new(chars_line).render(layout[4], buf);
let help = 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 help = if let Some(ms) = self.input_lock_remaining_ms {
Paragraph::new(Line::from(vec![
Span::styled(
" Input temporarily blocked ",
Style::default().fg(colors.warning()),
),
Span::styled(
format!("({ms}ms remaining)"),
Style::default()
.fg(colors.warning())
.add_modifier(Modifier::BOLD),
),
]))
} 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())),
]))
};
help.render(layout[6], buf);
}
}

View File

@@ -267,6 +267,22 @@ impl KeyboardDiagram<'_> {
}
let offsets: &[u16] = &[3, 4, 6];
let keyboard_width = letter_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 + 3, // [B]
1 => row_end + 3, // [E]
2 => row_end + 3, // [S]
_ => row_end,
}
})
.max()
.unwrap_or(0);
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
for (row_idx, row) in letter_rows.iter().enumerate() {
let y = inner.y + row_idx as u16;
@@ -283,18 +299,18 @@ impl KeyboardDiagram<'_> {
let is_next = self.next_key == Some(TAB);
let is_sel = self.is_sentinel_selected(TAB);
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
buf.set_string(inner.x, y, "[T]", style);
buf.set_string(start_x, y, "[T]", style);
}
2 => {
let is_dep = self.shift_held;
let style = modifier_key_style(is_dep, false, false, colors);
buf.set_string(inner.x, y, "[S]", style);
buf.set_string(start_x, y, "[S]", style);
}
_ => {}
}
for (col_idx, physical_key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_width;
let x = start_x + offset + col_idx as u16 * key_width;
if x + key_width > inner.x + inner.width {
break;
}
@@ -326,7 +342,7 @@ impl KeyboardDiagram<'_> {
}
// Render trailing modifier key
let row_end_x = inner.x + offset + row.len() as u16 * key_width;
let row_end_x = start_x + offset + row.len() as u16 * key_width;
match row_idx {
1 => {
if row_end_x + 3 <= inner.x + inner.width {
@@ -351,7 +367,7 @@ impl KeyboardDiagram<'_> {
// Backspace at end of first row
if inner.height >= 3 {
let y = inner.y;
let row_end_x = inner.x + offsets[0] + letter_rows[0].len() as u16 * key_width;
let row_end_x = start_x + offsets[0] + letter_rows[0].len() as u16 * key_width;
if row_end_x + 3 <= inner.x + inner.width {
let is_dep = self.depressed_keys.contains(&BACKSPACE);
let is_next = self.next_key == Some(BACKSPACE);
@@ -373,6 +389,24 @@ impl KeyboardDiagram<'_> {
}
let offsets: &[u16] = &[0, 5, 5, 6];
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);
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
for (row_idx, row) in self.model.rows.iter().enumerate() {
let y = inner.y + row_idx as u16;
@@ -391,7 +425,7 @@ impl KeyboardDiagram<'_> {
let is_sel = self.is_sentinel_selected(TAB);
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
let label = format!("[{}]", display::key_short_label(TAB));
buf.set_string(inner.x, y, &label, style);
buf.set_string(start_x, y, &label, style);
}
}
2 => {
@@ -401,10 +435,10 @@ impl KeyboardDiagram<'_> {
let style = Style::default()
.fg(readable_fg(bg, colors.warning()))
.bg(bg);
buf.set_string(inner.x, y, "[Cap]", style);
buf.set_string(start_x, y, "[Cap]", style);
} else {
let style = Style::default().fg(colors.text_pending()).bg(colors.bg());
buf.set_string(inner.x, y, "[ ]", style);
buf.set_string(start_x, y, "[ ]", style);
}
}
}
@@ -412,14 +446,14 @@ impl KeyboardDiagram<'_> {
if offset >= 6 {
let is_dep = self.shift_held;
let style = modifier_key_style(is_dep, false, false, colors);
buf.set_string(inner.x, y, "[Shft]", style);
buf.set_string(start_x, y, "[Shft]", style);
}
}
_ => {}
}
for (col_idx, physical_key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_width;
let x = start_x + offset + col_idx as u16 * key_width;
if x + key_width > inner.x + inner.width {
break;
}
@@ -451,7 +485,7 @@ impl KeyboardDiagram<'_> {
}
// Render trailing modifier keys
let after_x = inner.x + offset + row.len() as u16 * key_width;
let after_x = start_x + offset + row.len() as u16 * key_width;
match row_idx {
0 => {
if after_x + 6 <= inner.x + inner.width {
@@ -484,34 +518,13 @@ 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 + (keyboard_width.saturating_sub(space_width)) / 2;
let space_x = start_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);
@@ -527,6 +540,16 @@ impl KeyboardDiagram<'_> {
let letter_rows = self.model.letter_rows();
let key_width: u16 = 5;
let offsets: &[u16] = &[1, 3, 5];
let keyboard_width = letter_rows
.iter()
.enumerate()
.map(|(row_idx, row)| {
let offset = offsets.get(row_idx).copied().unwrap_or(0);
offset + row.len() as u16 * key_width
})
.max()
.unwrap_or(0);
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
if inner.height < 3 || inner.width < 30 {
return;
@@ -541,7 +564,7 @@ impl KeyboardDiagram<'_> {
let offset = offsets.get(row_idx).copied().unwrap_or(0);
for (col_idx, physical_key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_width;
let x = start_x + offset + col_idx as u16 * key_width;
if x + key_width > inner.x + inner.width {
break;
}

View File

@@ -118,8 +118,8 @@ impl Widget for SkillTreeWidget<'_> {
let notice_lines = footer_notice
.map(|text| wrapped_line_count(text, inner.width as usize))
.unwrap_or(0);
let show_notice =
footer_notice.is_some() && (inner.height as usize >= hint_lines.len() + notice_lines + 8);
let show_notice = footer_notice.is_some()
&& (inner.height as usize >= hint_lines.len() + notice_lines + 8);
let footer_needed = hint_lines.len() + if show_notice { notice_lines } else { 0 } + 1;
let footer_height = footer_needed
.min(inner.height.saturating_sub(5) as usize)
@@ -161,7 +161,10 @@ impl Widget for SkillTreeWidget<'_> {
}
}
footer_lines.extend(hint_lines.into_iter().map(|line| {
Line::from(Span::styled(line, Style::default().fg(colors.text_pending())))
Line::from(Span::styled(
line,
Style::default().fg(colors.text_pending()),
))
}));
let footer = Paragraph::new(footer_lines).wrap(Wrap { trim: false });
footer.render(layout[3], buf);

View File

@@ -6,12 +6,39 @@ use ratatui::widgets::{Block, Clear, Paragraph, Widget};
use std::collections::{BTreeSet, HashMap};
use crate::engine::key_stats::KeyStatsStore;
use crate::engine::ngram_stats::{AnomalyType, FocusSelection};
use crate::keyboard::display::{self, BACKSPACE, ENTER, MODIFIER_SENTINELS, SPACE, TAB};
use crate::keyboard::model::KeyboardModel;
use crate::session::result::DrillResult;
use crate::ui::components::activity_heatmap::ActivityHeatmap;
use crate::ui::theme::Theme;
// ---------------------------------------------------------------------------
// N-grams tab view models
// ---------------------------------------------------------------------------
pub struct AnomalyBigramRow {
pub bigram: String,
pub anomaly_pct: f64,
pub sample_count: usize,
pub error_count: usize,
pub error_rate_ema: f64,
pub speed_ms: f64,
pub expected_baseline: f64,
pub confirmed: bool,
}
pub struct NgramTabData {
pub focus: FocusSelection,
pub error_anomalies: Vec<AnomalyBigramRow>,
pub speed_anomalies: Vec<AnomalyBigramRow>,
pub total_bigrams: usize,
pub total_trigrams: usize,
pub hesitation_threshold_ms: f64,
pub latest_trigram_gain: Option<f64>,
pub scope_label: String,
}
pub struct StatsDashboard<'a> {
pub history: &'a [DrillResult],
pub key_stats: &'a KeyStatsStore,
@@ -24,6 +51,7 @@ pub struct StatsDashboard<'a> {
pub history_selected: usize,
pub history_confirm_delete: bool,
pub keyboard_model: &'a KeyboardModel,
pub ngram_data: Option<&'a NgramTabData>,
}
impl<'a> StatsDashboard<'a> {
@@ -39,6 +67,7 @@ impl<'a> StatsDashboard<'a> {
history_selected: usize,
history_confirm_delete: bool,
keyboard_model: &'a KeyboardModel,
ngram_data: Option<&'a NgramTabData>,
) -> Self {
Self {
history,
@@ -52,6 +81,7 @@ impl<'a> StatsDashboard<'a> {
history_selected,
history_confirm_delete,
keyboard_model,
ngram_data,
}
}
}
@@ -92,6 +122,7 @@ impl Widget for StatsDashboard<'_> {
"[3] Activity",
"[4] Accuracy",
"[5] Timing",
"[6] N-grams",
];
let tab_spans: Vec<Span> = tabs
.iter()
@@ -114,9 +145,9 @@ impl Widget for StatsDashboard<'_> {
// Footer
let footer_text = if self.active_tab == 1 {
" [ESC] Back [Tab] Next tab [1-5] Switch tab [j/k] Navigate [x] Delete"
" [ESC] Back [Tab] Next tab [1-6] Switch tab [j/k] Navigate [x] Delete"
} else {
" [ESC] Back [Tab] Next tab [1-5] Switch tab"
" [ESC] Back [Tab] Next tab [1-6] Switch tab"
};
let footer = Paragraph::new(Line::from(Span::styled(
footer_text,
@@ -163,6 +194,7 @@ impl StatsDashboard<'_> {
2 => self.render_activity_tab(area, buf),
3 => self.render_accuracy_tab(area, buf),
4 => self.render_timing_tab(area, buf),
5 => self.render_ngram_tab(area, buf),
_ => {}
}
}
@@ -692,6 +724,17 @@ impl StatsDashboard<'_> {
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
let all_rows = &self.keyboard_model.rows;
let offsets: &[u16] = &[0, 2, 3, 4];
let kbd_width = all_rows
.iter()
.enumerate()
.map(|(i, row)| {
let off = offsets.get(i).copied().unwrap_or(0);
off + row.len() as u16 * key_step
})
.max()
.unwrap_or(inner.width)
.min(inner.width);
let keyboard_x = inner.x + inner.width.saturating_sub(kbd_width) / 2;
for (row_idx, row) in all_rows.iter().enumerate() {
let base_y = if show_shifted {
@@ -711,7 +754,7 @@ impl StatsDashboard<'_> {
let shifted_y = base_y - 1;
if shifted_y >= inner.y {
for (col_idx, physical_key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_step;
let x = keyboard_x + offset + col_idx as u16 * key_step;
if x + key_width > inner.x + inner.width {
break;
}
@@ -733,7 +776,7 @@ impl StatsDashboard<'_> {
// Base row
for (col_idx, physical_key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_step;
let x = keyboard_x + offset + col_idx as u16 * key_step;
if x + key_width > inner.x + inner.width {
break;
}
@@ -745,20 +788,8 @@ impl StatsDashboard<'_> {
let display = format_accuracy_cell(key, accuracy, key_width);
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
}
}
// Modifier key stats row below the keyboard, spread across keyboard width
let kbd_width = all_rows
.iter()
.enumerate()
.map(|(i, row)| {
let off = offsets.get(i).copied().unwrap_or(0);
off + row.len() as u16 * key_step
})
.max()
.unwrap_or(inner.width)
.min(inner.width);
let mod_y = if show_shifted {
inner.y + all_rows.len() as u16 * 2 + 1
} else {
@@ -783,7 +814,7 @@ impl StatsDashboard<'_> {
let accuracy = self.get_key_accuracy(key);
let fg_color = accuracy_color(accuracy, colors);
buf.set_string(
inner.x + positions[i],
keyboard_x + positions[i],
mod_y,
&labels[i],
Style::default().fg(fg_color),
@@ -848,6 +879,17 @@ impl StatsDashboard<'_> {
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
let all_rows = &self.keyboard_model.rows;
let offsets: &[u16] = &[0, 2, 3, 4];
let kbd_width = all_rows
.iter()
.enumerate()
.map(|(i, row)| {
let off = offsets.get(i).copied().unwrap_or(0);
off + row.len() as u16 * key_step
})
.max()
.unwrap_or(inner.width)
.min(inner.width);
let keyboard_x = inner.x + inner.width.saturating_sub(kbd_width) / 2;
for (row_idx, row) in all_rows.iter().enumerate() {
let base_y = if show_shifted {
@@ -866,7 +908,7 @@ impl StatsDashboard<'_> {
let shifted_y = base_y - 1;
if shifted_y >= inner.y {
for (col_idx, physical_key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_step;
let x = keyboard_x + offset + col_idx as u16 * key_step;
if x + key_width > inner.x + inner.width {
break;
}
@@ -886,7 +928,7 @@ impl StatsDashboard<'_> {
}
for (col_idx, physical_key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_step;
let x = keyboard_x + offset + col_idx as u16 * key_step;
if x + key_width > inner.x + inner.width {
break;
}
@@ -897,20 +939,8 @@ impl StatsDashboard<'_> {
let display = format_timing_cell(key, time_ms, key_width);
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
}
}
// Modifier key stats row below the keyboard, spread across keyboard width
let kbd_width = all_rows
.iter()
.enumerate()
.map(|(i, row)| {
let off = offsets.get(i).copied().unwrap_or(0);
off + row.len() as u16 * key_step
})
.max()
.unwrap_or(inner.width)
.min(inner.width);
let mod_y = if show_shifted {
inner.y + all_rows.len() as u16 * 2 + 1
} else {
@@ -935,7 +965,7 @@ impl StatsDashboard<'_> {
let time_ms = self.get_key_time_ms(key);
let fg_color = timing_color(time_ms, colors);
buf.set_string(
inner.x + positions[i],
keyboard_x + positions[i],
mod_y,
&labels[i],
Style::default().fg(fg_color),
@@ -1261,6 +1291,334 @@ impl StatsDashboard<'_> {
Paragraph::new(lines).render(inner, buf);
}
// --- N-grams tab ---
fn render_ngram_tab(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let data = match self.ngram_data {
Some(d) => d,
None => {
let msg = Paragraph::new(Line::from(Span::styled(
"Complete some adaptive drills to see n-gram data",
Style::default().fg(colors.text_pending()),
)));
msg.render(area, buf);
return;
}
};
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4), // focus box
Constraint::Min(5), // lists
Constraint::Length(2), // summary
])
.split(area);
self.render_ngram_focus(data, layout[0], buf);
let wide = layout[1].width >= 60;
if wide {
let lists = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(layout[1]);
self.render_error_anomalies(data, lists[0], buf);
self.render_speed_anomalies(data, lists[1], buf);
} else {
// Stacked vertically for narrow terminals
let available = layout[1].height;
if available < 10 {
// Only show error anomalies if very little space
self.render_error_anomalies(data, layout[1], buf);
} else {
let half = available / 2;
let lists = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(half), Constraint::Min(0)])
.split(layout[1]);
self.render_error_anomalies(data, lists[0], buf);
self.render_speed_anomalies(data, lists[1], buf);
}
}
self.render_ngram_summary(data, layout[2], buf);
}
fn render_ngram_focus(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(Line::from(Span::styled(
" Active Focus ",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)))
.border_style(Style::default().fg(colors.accent()));
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 1 {
return;
}
let mut lines = Vec::new();
match (&data.focus.char_focus, &data.focus.bigram_focus) {
(Some(ch), Some((key, anomaly_pct, anomaly_type))) => {
let bigram_label = format!("\"{}{}\"", key.0[0], key.0[1]);
// Line 1: both focuses
lines.push(Line::from(vec![
Span::styled(" Focus: ", Style::default().fg(colors.fg())),
Span::styled(
format!("Char '{ch}'"),
Style::default()
.fg(colors.focused_key())
.add_modifier(Modifier::BOLD),
),
Span::styled(" + ", Style::default().fg(colors.fg())),
Span::styled(
format!("Bigram {bigram_label}"),
Style::default()
.fg(colors.focused_key())
.add_modifier(Modifier::BOLD),
),
]));
// Line 2: details
if inner.height >= 2 {
let type_label = match anomaly_type {
AnomalyType::Error => "error",
AnomalyType::Speed => "speed",
};
let detail = format!(
" Char '{ch}': weakest key | Bigram {bigram_label}: {type_label} anomaly {anomaly_pct:.0}%"
);
lines.push(Line::from(Span::styled(
detail,
Style::default().fg(colors.text_pending()),
)));
}
}
(Some(ch), None) => {
lines.push(Line::from(vec![
Span::styled(" Focus: ", Style::default().fg(colors.fg())),
Span::styled(
format!("Char '{ch}'"),
Style::default()
.fg(colors.focused_key())
.add_modifier(Modifier::BOLD),
),
]));
if inner.height >= 2 {
lines.push(Line::from(Span::styled(
format!(" Char '{ch}': weakest key, no confirmed bigram anomalies"),
Style::default().fg(colors.text_pending()),
)));
}
}
(None, Some((key, anomaly_pct, anomaly_type))) => {
let bigram_label = format!("\"{}{}\"", key.0[0], key.0[1]);
let type_label = match anomaly_type {
AnomalyType::Error => "error",
AnomalyType::Speed => "speed",
};
lines.push(Line::from(vec![
Span::styled(" Focus: ", Style::default().fg(colors.fg())),
Span::styled(
format!("Bigram {bigram_label}"),
Style::default()
.fg(colors.focused_key())
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" ({type_label} anomaly: {anomaly_pct:.0}%)"),
Style::default().fg(colors.text_pending()),
),
]));
}
(None, None) => {
lines.push(Line::from(Span::styled(
" Complete some adaptive drills to see focus data",
Style::default().fg(colors.text_pending()),
)));
}
}
Paragraph::new(lines).render(inner, buf);
}
fn render_anomaly_panel(
&self,
title: &str,
empty_msg: &str,
rows: &[AnomalyBigramRow],
is_speed: bool,
area: Rect,
buf: &mut Buffer,
) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(Line::from(Span::styled(
title.to_string(),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)))
.border_style(Style::default().fg(colors.accent()));
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 1 {
return;
}
if rows.is_empty() {
buf.set_string(
inner.x,
inner.y,
empty_msg,
Style::default().fg(colors.text_pending()),
);
return;
}
let narrow = inner.width < 30;
// Error table: Bigram Anom% Rate Errors Smp Strk
// Speed table: Bigram Anom% Speed Smp Strk
let header = if narrow {
if is_speed {
" Bgrm Speed Expct Anom%"
} else {
" Bgrm Err Smp Rate Exp Anom%"
}
} else if is_speed {
" Bigram Speed Expect Samples Anom%"
} else {
" Bigram Errors Samples Rate Expect Anom%"
};
buf.set_string(
inner.x,
inner.y,
header,
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
);
let max_rows = (inner.height as usize).saturating_sub(1);
for (i, row) in rows.iter().take(max_rows).enumerate() {
let y = inner.y + 1 + i as u16;
if y >= inner.y + inner.height {
break;
}
let line = if narrow {
if is_speed {
format!(
" {:>4} {:>3.0}ms {:>3.0}ms {:>4.0}%",
row.bigram, row.speed_ms, row.expected_baseline, row.anomaly_pct,
)
} else {
format!(
" {:>4} {:>3} {:>3} {:>3.0}% {:>2.0}% {:>4.0}%",
row.bigram,
row.error_count,
row.sample_count,
row.error_rate_ema * 100.0,
row.expected_baseline * 100.0,
row.anomaly_pct,
)
}
} else if is_speed {
format!(
" {:>6} {:>4.0}ms {:>4.0}ms {:>5} {:>4.0}%",
row.bigram,
row.speed_ms,
row.expected_baseline,
row.sample_count,
row.anomaly_pct,
)
} else {
format!(
" {:>6} {:>5} {:>5} {:>4.0}% {:>4.0}% {:>5.0}%",
row.bigram,
row.error_count,
row.sample_count,
row.error_rate_ema * 100.0,
row.expected_baseline * 100.0,
row.anomaly_pct,
)
};
let color = if row.confirmed {
colors.error()
} else {
colors.warning()
};
buf.set_string(inner.x, y, &line, Style::default().fg(color));
}
}
fn render_error_anomalies(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
let title = format!(" Error Anomalies ({}) ", data.error_anomalies.len());
self.render_anomaly_panel(
&title,
" No error anomalies detected",
&data.error_anomalies,
false,
area,
buf,
);
}
fn render_speed_anomalies(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
let title = format!(" Speed Anomalies ({}) ", data.speed_anomalies.len());
self.render_anomaly_panel(
&title,
" No speed anomalies detected",
&data.speed_anomalies,
true,
area,
buf,
);
}
fn render_ngram_summary(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let gain_str = match data.latest_trigram_gain {
Some(g) => format!("{:.1}%", g * 100.0),
None => "--".to_string(),
};
let gain_note = if data.latest_trigram_gain.is_none() {
" (computed every 50 drills)"
} 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,
);
buf.set_string(
area.x,
area.y,
&line,
Style::default().fg(colors.text_pending()),
);
}
}
fn accuracy_color(accuracy: f64, colors: &crate::ui::theme::ThemeColors) -> ratatui::style::Color {
@@ -1501,3 +1859,79 @@ fn format_duration(secs: f64) -> String {
format!("{s}s")
}
}
/// Compute the ngram tab panel layout for the given terminal area.
/// Returns `(wide, lists_area_height)` where:
/// - `wide` = true means side-by-side anomaly panels (width >= 60)
/// - `lists_area_height` = height available for the anomaly panels region
///
/// When `!wide && lists_area_height < 10`, only error anomalies should render.
#[cfg(test)]
fn ngram_panel_layout(area: Rect) -> (bool, u16) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4), // focus box
Constraint::Min(5), // lists
Constraint::Length(2), // summary
])
.split(area);
let wide = layout[1].width >= 60;
(wide, layout[1].height)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn narrow_short_terminal_shows_only_error_panel() {
// 50 cols × 15 rows: narrow (<60) so panels stack vertically.
// lists area = 15 - 4 (focus) - 2 (summary) = 9 rows → < 10 → error only.
let area = Rect::new(0, 0, 50, 15);
let (wide, lists_height) = ngram_panel_layout(area);
assert!(!wide, "50 cols should be narrow layout");
assert!(
lists_height < 10,
"lists_height={lists_height}, expected < 10 so only error panel renders"
);
}
#[test]
fn narrow_tall_terminal_stacks_both_panels() {
// 50 cols × 30 rows: narrow (<60) so panels stack vertically.
// lists area = 30 - 4 - 2 = 24 rows → >= 10 → both panels stacked.
let area = Rect::new(0, 0, 50, 30);
let (wide, lists_height) = ngram_panel_layout(area);
assert!(!wide, "50 cols should be narrow layout");
assert!(
lists_height >= 10,
"lists_height={lists_height}, expected >= 10 so both panels stack vertically"
);
}
#[test]
fn wide_terminal_shows_side_by_side_panels() {
// 80 cols × 24 rows: wide (>= 60) so panels render side by side.
let area = Rect::new(0, 0, 80, 24);
let (wide, _) = ngram_panel_layout(area);
assert!(
wide,
"80 cols should be wide layout with side-by-side panels"
);
}
#[test]
fn boundary_width_59_is_narrow() {
let area = Rect::new(0, 0, 59, 24);
let (wide, _) = ngram_panel_layout(area);
assert!(!wide, "59 cols should be narrow");
}
#[test]
fn boundary_width_60_is_wide() {
let area = Rect::new(0, 0, 60, 24);
let (wide, _) = ngram_panel_layout(area);
assert!(wide, "60 cols should be wide");
}
}

View File

@@ -103,7 +103,9 @@ fn contrast_ratio(a: ratatui::style::Color, b: ratatui::style::Color) -> f64 {
(hi + 0.05) / (lo + 0.05)
}
fn choose_cursor_colors(colors: &crate::ui::theme::ThemeColors) -> (ratatui::style::Color, ratatui::style::Color) {
fn choose_cursor_colors(
colors: &crate::ui::theme::ThemeColors,
) -> (ratatui::style::Color, ratatui::style::Color) {
use ratatui::style::Color;
let base_bg = colors.bg();
@@ -113,7 +115,13 @@ fn choose_cursor_colors(colors: &crate::ui::theme::ThemeColors) -> (ratatui::sty
if contrast_ratio(cursor_bg, base_bg) < 1.8 {
let mut best_bg = cursor_bg;
let mut best_ratio = contrast_ratio(cursor_bg, base_bg);
for candidate in [colors.accent(), colors.focused_key(), colors.warning(), Color::Black, Color::White] {
for candidate in [
colors.accent(),
colors.focused_key(),
colors.warning(),
Color::Black,
Color::White,
] {
let ratio = contrast_ratio(candidate, base_bg);
if ratio > best_ratio {
best_bg = candidate;