Prevent tests from writing to user data
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
# Prevent Tests from Writing to Real User Data
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Two tests in `src/app.rs` (`adaptive_auto_continue_arms_input_lock` and `adaptive_does_not_auto_continue_with_milestones`) call `App::new()` which connects to the real `JsonStore` at `~/.local/share/keydr/`. When they call `finish_drill()` → `save_data()`, fake drill results get persisted to the user's actual history file. All other app tests also use `App::new()` but happen to not call `finish_drill()`.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### 1. Add `#[cfg(not(test))]` gate on `App::new()` (`src/app.rs:293`)
|
||||||
|
|
||||||
|
Mark `App::new()` with `#[cfg(not(test))]` so it cannot be called from test code at all. This is a compile-time guarantee — any future test that tries `App::new()` will fail to compile.
|
||||||
|
|
||||||
|
### 2. Add `App::new_test()` (`src/app.rs`, in `#[cfg(test)]` block)
|
||||||
|
|
||||||
|
Add a `pub fn new_test()` constructor inside a `#[cfg(test)] impl App` block that mirrors `App::new()` but sets `store: None`. This prevents any persistence to disk. All existing fields get their default/empty values (no loading from disk either).
|
||||||
|
|
||||||
|
Since most test fields just need defaults and a started drill, the test constructor can be minimal:
|
||||||
|
- `Config::default()`, `Theme::default()` (leaked), `Menu::new()`, `store: None`
|
||||||
|
- Default key stats, skill tree, profile, empty drill history
|
||||||
|
- `Dictionary::load()`, `TransitionTable`, `KeyboardModel` — same as production (needed for `start_drill()`)
|
||||||
|
- Call `start_drill()` at the end (same as `App::new()`)
|
||||||
|
|
||||||
|
### 3. Update all existing tests to use `App::new_test()`
|
||||||
|
|
||||||
|
Replace every `App::new()` call in the test module with `App::new_test()`. This covers all 7 tests in `#[cfg(test)] mod tests`.
|
||||||
|
|
||||||
|
## File to Modify
|
||||||
|
|
||||||
|
- `src/app.rs` — gate `new()`, add `new_test()`, update test calls
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `cargo test` — all tests pass
|
||||||
|
2. `cargo build` — production build still compiles (ungated `new()` available)
|
||||||
|
3. Temporarily add `App::new()` in a test → should fail to compile
|
||||||
119
src/app.rs
119
src/app.rs
@@ -2171,6 +2171,111 @@ fn insert_line_breaks(text: &str) -> String {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl App {
|
||||||
|
pub fn new_test() -> Self {
|
||||||
|
let config = Config::default();
|
||||||
|
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
|
||||||
|
let menu = Menu::new(theme);
|
||||||
|
let dictionary = Dictionary::load();
|
||||||
|
let transition_table = TransitionTable::build_from_words(&dictionary.words_list());
|
||||||
|
let keyboard_model = KeyboardModel::from_name(&config.keyboard_layout);
|
||||||
|
|
||||||
|
let mut app = Self {
|
||||||
|
screen: AppScreen::Menu,
|
||||||
|
drill_mode: DrillMode::Adaptive,
|
||||||
|
drill_scope: DrillScope::Global,
|
||||||
|
drill: None,
|
||||||
|
drill_events: Vec::new(),
|
||||||
|
last_result: None,
|
||||||
|
drill_history: Vec::new(),
|
||||||
|
menu,
|
||||||
|
theme,
|
||||||
|
config,
|
||||||
|
key_stats: KeyStatsStore::default(),
|
||||||
|
ranked_key_stats: KeyStatsStore::default(),
|
||||||
|
skill_tree: SkillTree::default(),
|
||||||
|
profile: ProfileData::default(),
|
||||||
|
store: None,
|
||||||
|
should_quit: false,
|
||||||
|
settings_selected: 0,
|
||||||
|
settings_editing_download_dir: false,
|
||||||
|
stats_tab: 0,
|
||||||
|
depressed_keys: HashSet::new(),
|
||||||
|
last_key_time: None,
|
||||||
|
history_selected: 0,
|
||||||
|
history_confirm_delete: false,
|
||||||
|
skill_tree_selected: 0,
|
||||||
|
skill_tree_detail_scroll: 0,
|
||||||
|
drill_source_info: None,
|
||||||
|
code_language_selected: 0,
|
||||||
|
code_language_scroll: 0,
|
||||||
|
passage_book_selected: 0,
|
||||||
|
passage_intro_selected: 0,
|
||||||
|
passage_intro_downloads_enabled: false,
|
||||||
|
passage_intro_download_dir: String::new(),
|
||||||
|
passage_intro_paragraph_limit: 0,
|
||||||
|
passage_intro_downloading: false,
|
||||||
|
passage_intro_download_total: 0,
|
||||||
|
passage_intro_downloaded: 0,
|
||||||
|
passage_intro_current_book: String::new(),
|
||||||
|
passage_intro_download_bytes: 0,
|
||||||
|
passage_intro_download_bytes_total: 0,
|
||||||
|
passage_download_queue: Vec::new(),
|
||||||
|
passage_drill_selection_override: None,
|
||||||
|
last_passage_drill_selection: None,
|
||||||
|
passage_download_action: PassageDownloadCompleteAction::StartPassageDrill,
|
||||||
|
code_intro_selected: 0,
|
||||||
|
code_intro_downloads_enabled: false,
|
||||||
|
code_intro_download_dir: String::new(),
|
||||||
|
code_intro_snippets_per_repo: 0,
|
||||||
|
code_intro_downloading: false,
|
||||||
|
code_intro_download_total: 0,
|
||||||
|
code_intro_downloaded: 0,
|
||||||
|
code_intro_current_repo: String::new(),
|
||||||
|
code_intro_download_bytes: 0,
|
||||||
|
code_intro_download_bytes_total: 0,
|
||||||
|
code_download_queue: Vec::new(),
|
||||||
|
code_drill_language_override: None,
|
||||||
|
last_code_drill_language: None,
|
||||||
|
code_download_attempted: false,
|
||||||
|
code_download_action: CodeDownloadCompleteAction::StartCodeDrill,
|
||||||
|
shift_held: false,
|
||||||
|
caps_lock: false,
|
||||||
|
keyboard_model,
|
||||||
|
milestone_queue: VecDeque::new(),
|
||||||
|
settings_confirm_import: false,
|
||||||
|
settings_export_conflict: false,
|
||||||
|
settings_status_message: None,
|
||||||
|
settings_export_path: default_export_path(),
|
||||||
|
settings_import_path: default_export_path(),
|
||||||
|
settings_editing_export_path: false,
|
||||||
|
settings_editing_import_path: false,
|
||||||
|
keyboard_explorer_selected: None,
|
||||||
|
explorer_accuracy_cache_overall: None,
|
||||||
|
explorer_accuracy_cache_ranked: None,
|
||||||
|
bigram_stats: BigramStatsStore::default(),
|
||||||
|
ranked_bigram_stats: BigramStatsStore::default(),
|
||||||
|
trigram_stats: TrigramStatsStore::default(),
|
||||||
|
ranked_trigram_stats: TrigramStatsStore::default(),
|
||||||
|
user_median_transition_ms: 0.0,
|
||||||
|
transition_buffer: Vec::new(),
|
||||||
|
trigram_gain_history: Vec::new(),
|
||||||
|
current_focus: None,
|
||||||
|
post_drill_input_lock_until: None,
|
||||||
|
adaptive_word_history: VecDeque::new(),
|
||||||
|
rng: SmallRng::from_entropy(),
|
||||||
|
transition_table,
|
||||||
|
dictionary,
|
||||||
|
passage_download_job: None,
|
||||||
|
code_download_job: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
app.start_drill();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -2178,7 +2283,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adaptive_word_history_clears_on_code_mode_switch() {
|
fn adaptive_word_history_clears_on_code_mode_switch() {
|
||||||
let mut app = App::new();
|
let mut app = App::new_test();
|
||||||
|
|
||||||
// App starts in Adaptive/Global; new() calls start_drill() which populates history
|
// App starts in Adaptive/Global; new() calls start_drill() which populates history
|
||||||
assert_eq!(app.drill_mode, DrillMode::Adaptive);
|
assert_eq!(app.drill_mode, DrillMode::Adaptive);
|
||||||
@@ -2200,7 +2305,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adaptive_word_history_clears_on_passage_mode_switch() {
|
fn adaptive_word_history_clears_on_passage_mode_switch() {
|
||||||
let mut app = App::new();
|
let mut app = App::new_test();
|
||||||
|
|
||||||
assert_eq!(app.drill_mode, DrillMode::Adaptive);
|
assert_eq!(app.drill_mode, DrillMode::Adaptive);
|
||||||
assert!(!app.adaptive_word_history.is_empty());
|
assert!(!app.adaptive_word_history.is_empty());
|
||||||
@@ -2219,7 +2324,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adaptive_word_history_clears_on_scope_change() {
|
fn adaptive_word_history_clears_on_scope_change() {
|
||||||
let mut app = App::new();
|
let mut app = App::new_test();
|
||||||
|
|
||||||
// Start in Adaptive/Global — drill already started in new()
|
// Start in Adaptive/Global — drill already started in new()
|
||||||
assert_eq!(app.drill_scope, DrillScope::Global);
|
assert_eq!(app.drill_scope, DrillScope::Global);
|
||||||
@@ -2266,7 +2371,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adaptive_word_history_persists_within_same_context() {
|
fn adaptive_word_history_persists_within_same_context() {
|
||||||
let mut app = App::new();
|
let mut app = App::new_test();
|
||||||
|
|
||||||
// Adaptive/Global: run multiple drills, history should accumulate
|
// Adaptive/Global: run multiple drills, history should accumulate
|
||||||
let history_after_first = app.adaptive_word_history.len();
|
let history_after_first = app.adaptive_word_history.len();
|
||||||
@@ -2287,7 +2392,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adaptive_word_history_not_cleared_on_same_branch_redrill() {
|
fn adaptive_word_history_not_cleared_on_same_branch_redrill() {
|
||||||
let mut app = App::new();
|
let mut app = App::new_test();
|
||||||
|
|
||||||
// Start a branch drill
|
// Start a branch drill
|
||||||
app.start_branch_drill(BranchId::Lowercase);
|
app.start_branch_drill(BranchId::Lowercase);
|
||||||
@@ -2319,7 +2424,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adaptive_auto_continue_arms_input_lock() {
|
fn adaptive_auto_continue_arms_input_lock() {
|
||||||
let mut app = App::new();
|
let mut app = App::new_test();
|
||||||
assert_eq!(app.drill_mode, DrillMode::Adaptive);
|
assert_eq!(app.drill_mode, DrillMode::Adaptive);
|
||||||
assert_eq!(app.screen, AppScreen::Drill);
|
assert_eq!(app.screen, AppScreen::Drill);
|
||||||
assert!(app.drill.is_some());
|
assert!(app.drill.is_some());
|
||||||
@@ -2340,7 +2445,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adaptive_does_not_auto_continue_with_milestones() {
|
fn adaptive_does_not_auto_continue_with_milestones() {
|
||||||
let mut app = App::new();
|
let mut app = App::new_test();
|
||||||
assert_eq!(app.drill_mode, DrillMode::Adaptive);
|
assert_eq!(app.drill_mode, DrillMode::Adaptive);
|
||||||
|
|
||||||
// Push a milestone before finishing the drill
|
// Push a milestone before finishing the drill
|
||||||
|
|||||||
77
src/main.rs
77
src/main.rs
@@ -36,6 +36,7 @@ use generator::passage::{is_book_cached, passage_options};
|
|||||||
use keyboard::display::key_display_name;
|
use keyboard::display::key_display_name;
|
||||||
use keyboard::finger::Hand;
|
use keyboard::finger::Hand;
|
||||||
use ui::components::dashboard::Dashboard;
|
use ui::components::dashboard::Dashboard;
|
||||||
|
use ui::layout::{pack_hint_lines, wrapped_line_count};
|
||||||
use ui::components::keyboard_diagram::KeyboardDiagram;
|
use ui::components::keyboard_diagram::KeyboardDiagram;
|
||||||
use ui::components::skill_tree::{SkillTreeWidget, detail_line_count, selectable_branches};
|
use ui::components::skill_tree::{SkillTreeWidget, detail_line_count, selectable_branches};
|
||||||
use ui::components::stats_dashboard::{AnomalyBigramRow, NgramTabData, StatsDashboard};
|
use ui::components::stats_dashboard::{AnomalyBigramRow, NgramTabData, StatsDashboard};
|
||||||
@@ -1083,12 +1084,23 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) {
|
|||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
let colors = &app.theme.colors;
|
let colors = &app.theme.colors;
|
||||||
|
|
||||||
|
let menu_hints = [
|
||||||
|
"[1-3] Start",
|
||||||
|
"[t] Skill Tree",
|
||||||
|
"[b] Keyboard",
|
||||||
|
"[s] Stats",
|
||||||
|
"[c] Settings",
|
||||||
|
"[q] Quit",
|
||||||
|
];
|
||||||
|
let footer_lines_vec = pack_hint_lines(&menu_hints, area.width as usize);
|
||||||
|
let footer_line_count = footer_lines_vec.len().max(1) as u16;
|
||||||
|
|
||||||
let layout = Layout::default()
|
let layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(3),
|
Constraint::Length(3),
|
||||||
Constraint::Min(0),
|
Constraint::Min(0),
|
||||||
Constraint::Length(1),
|
Constraint::Length(footer_line_count),
|
||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
@@ -1125,10 +1137,16 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) {
|
|||||||
let menu_area = ui::layout::centered_rect(50, 80, layout[1]);
|
let menu_area = ui::layout::centered_rect(50, 80, layout[1]);
|
||||||
frame.render_widget(&app.menu, menu_area);
|
frame.render_widget(&app.menu, menu_area);
|
||||||
|
|
||||||
let footer = Paragraph::new(Line::from(vec![Span::styled(
|
let footer_lines: Vec<Line> = footer_lines_vec
|
||||||
" [1-3] Start [t] Skill Tree [b] Keyboard [s] Stats [c] Settings [q] Quit ",
|
.into_iter()
|
||||||
Style::default().fg(colors.text_pending()),
|
.map(|line| {
|
||||||
)]));
|
Line::from(Span::styled(
|
||||||
|
line,
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let footer = Paragraph::new(footer_lines);
|
||||||
frame.render_widget(footer, layout[2]);
|
frame.render_widget(footer, layout[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1529,9 +1547,7 @@ mod review_tests {
|
|||||||
/// Create an App for testing with the store disabled so tests never
|
/// Create an App for testing with the store disabled so tests never
|
||||||
/// read or write the user's real data files.
|
/// read or write the user's real data files.
|
||||||
fn test_app() -> App {
|
fn test_app() -> App {
|
||||||
let mut app = App::new();
|
App::new_test()
|
||||||
app.store = None;
|
|
||||||
app
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn test_result(ts_offset_secs: i64) -> DrillResult {
|
fn test_result(ts_offset_secs: i64) -> DrillResult {
|
||||||
@@ -2832,51 +2848,6 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_code_language_select(frame: &mut ratatui::Frame, app: &App) {
|
fn render_code_language_select(frame: &mut ratatui::Frame, app: &App) {
|
||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::{Block, Paragraph, Widget};
|
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||||
|
|
||||||
use crate::session::result::DrillResult;
|
use crate::session::result::DrillResult;
|
||||||
|
use crate::ui::layout::pack_hint_lines;
|
||||||
use crate::ui::theme::Theme;
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
pub struct Dashboard<'a> {
|
pub struct Dashboard<'a> {
|
||||||
@@ -38,6 +39,19 @@ impl Widget for Dashboard<'_> {
|
|||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
block.render(area, buf);
|
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()
|
let layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
@@ -47,7 +61,7 @@ impl Widget for Dashboard<'_> {
|
|||||||
Constraint::Length(3),
|
Constraint::Length(3),
|
||||||
Constraint::Length(3),
|
Constraint::Length(3),
|
||||||
Constraint::Min(0),
|
Constraint::Min(0),
|
||||||
Constraint::Length(2),
|
Constraint::Length(footer_line_count),
|
||||||
])
|
])
|
||||||
.split(inner);
|
.split(inner);
|
||||||
|
|
||||||
@@ -137,16 +151,23 @@ impl Widget for Dashboard<'_> {
|
|||||||
),
|
),
|
||||||
]))
|
]))
|
||||||
} else {
|
} else {
|
||||||
Paragraph::new(Line::from(vec![
|
let hints = [
|
||||||
Span::styled(
|
"[c/Enter/Space] Continue",
|
||||||
" [c/Enter/Space] Continue ",
|
"[r] Retry",
|
||||||
Style::default().fg(colors.accent()),
|
"[q] Menu",
|
||||||
),
|
"[s] Stats",
|
||||||
Span::styled("[r] Retry ", Style::default().fg(colors.accent())),
|
"[x] Delete",
|
||||||
Span::styled("[q] Menu ", Style::default().fg(colors.accent())),
|
];
|
||||||
Span::styled("[s] Stats ", Style::default().fg(colors.accent())),
|
let lines: Vec<Line> = pack_hint_lines(&hints, inner.width as usize)
|
||||||
Span::styled("[x] Delete", Style::default().fg(colors.accent())),
|
.into_iter()
|
||||||
]))
|
.map(|line| {
|
||||||
|
Line::from(Span::styled(
|
||||||
|
line,
|
||||||
|
Style::default().fg(colors.accent()),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Paragraph::new(lines)
|
||||||
};
|
};
|
||||||
help.render(layout[6], buf);
|
help.render(layout[6], buf);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::engine::key_stats::KeyStatsStore;
|
|||||||
use crate::engine::skill_tree::{
|
use crate::engine::skill_tree::{
|
||||||
BranchId, BranchStatus, DrillScope, SkillTree as SkillTreeEngine, get_branch_definition,
|
BranchId, BranchStatus, DrillScope, SkillTree as SkillTreeEngine, get_branch_definition,
|
||||||
};
|
};
|
||||||
|
use crate::ui::layout::{pack_hint_lines, wrapped_line_count};
|
||||||
use crate::ui::theme::Theme;
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
pub struct SkillTreeWidget<'a> {
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use crate::keyboard::display::{self, BACKSPACE, ENTER, MODIFIER_SENTINELS, SPACE
|
|||||||
use crate::keyboard::model::KeyboardModel;
|
use crate::keyboard::model::KeyboardModel;
|
||||||
use crate::session::result::DrillResult;
|
use crate::session::result::DrillResult;
|
||||||
use crate::ui::components::activity_heatmap::ActivityHeatmap;
|
use crate::ui::components::activity_heatmap::ActivityHeatmap;
|
||||||
|
use crate::ui::layout::pack_hint_lines;
|
||||||
use crate::ui::theme::Theme;
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -106,17 +107,8 @@ impl Widget for StatsDashboard<'_> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let layout = Layout::default()
|
// Tab header — width-aware wrapping
|
||||||
.direction(Direction::Vertical)
|
let tab_labels = [
|
||||||
.constraints([
|
|
||||||
Constraint::Length(2),
|
|
||||||
Constraint::Min(10),
|
|
||||||
Constraint::Length(2),
|
|
||||||
])
|
|
||||||
.split(inner);
|
|
||||||
|
|
||||||
// Tab header
|
|
||||||
let tabs = [
|
|
||||||
"[1] Dashboard",
|
"[1] Dashboard",
|
||||||
"[2] History",
|
"[2] History",
|
||||||
"[3] Activity",
|
"[3] Activity",
|
||||||
@@ -124,10 +116,20 @@ impl Widget for StatsDashboard<'_> {
|
|||||||
"[5] Timing",
|
"[5] Timing",
|
||||||
"[6] N-grams",
|
"[6] N-grams",
|
||||||
];
|
];
|
||||||
let tab_spans: Vec<Span> = tabs
|
let tab_separator = " ";
|
||||||
.iter()
|
let width = inner.width as usize;
|
||||||
.enumerate()
|
let mut tab_lines: Vec<Line> = Vec::new();
|
||||||
.flat_map(|(i, &label)| {
|
{
|
||||||
|
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 {
|
let style = if i == self.active_tab {
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(colors.accent())
|
.fg(colors.accent())
|
||||||
@@ -135,25 +137,56 @@ impl Widget for StatsDashboard<'_> {
|
|||||||
} else {
|
} else {
|
||||||
Style::default().fg(colors.text_pending())
|
Style::default().fg(colors.text_pending())
|
||||||
};
|
};
|
||||||
vec![Span::styled(format!(" {label} "), style), Span::raw(" ")]
|
current_spans.push(Span::styled(styled_label, style));
|
||||||
})
|
current_spans.push(Span::raw(tab_separator));
|
||||||
.collect();
|
current_width += item_width;
|
||||||
Paragraph::new(Line::from(tab_spans)).render(layout[0], buf);
|
}
|
||||||
|
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.
|
// Render only one tab at a time so each tab gets full breathing room.
|
||||||
self.render_tab(self.active_tab, layout[1], buf);
|
self.render_tab(self.active_tab, layout[1], buf);
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
let footer_text = if self.active_tab == 1 {
|
let footer_lines: Vec<Line> = footer_lines_vec
|
||||||
" [ESC] Back [Tab] Next tab [1-6] Switch tab [j/k] Navigate [x] Delete"
|
.into_iter()
|
||||||
} else {
|
.map(|line| {
|
||||||
" [ESC] Back [Tab] Next tab [1-6] Switch tab"
|
Line::from(Span::styled(
|
||||||
};
|
line,
|
||||||
let footer = Paragraph::new(Line::from(Span::styled(
|
Style::default().fg(colors.accent()),
|
||||||
footer_text,
|
))
|
||||||
Style::default().fg(colors.accent()),
|
})
|
||||||
)));
|
.collect();
|
||||||
footer.render(layout[2], buf);
|
Paragraph::new(footer_lines).render(layout[2], buf);
|
||||||
|
|
||||||
// Confirmation dialog overlay
|
// Confirmation dialog overlay
|
||||||
if self.history_confirm_delete && self.active_tab == 1 {
|
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) {
|
fn render_ngram_summary(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
|
||||||
let colors = &self.theme.colors;
|
let colors = &self.theme.colors;
|
||||||
|
let w = area.width as usize;
|
||||||
|
|
||||||
let gain_str = match data.latest_trigram_gain {
|
let gain_str = match data.latest_trigram_gain {
|
||||||
Some(g) => format!("{:.1}%", g * 100.0),
|
Some(g) => format!("{:.1}%", g * 100.0),
|
||||||
None => "--".to_string(),
|
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() {
|
let gain_note = if data.latest_trigram_gain.is_none() {
|
||||||
" (computed every 50 drills)"
|
" (every 50)"
|
||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
};
|
};
|
||||||
|
|
||||||
let line = format!(
|
let segments: &[&str] = &[&scope, &bigrams, &trigrams, &hesitation, &gain, gain_note];
|
||||||
" Scope: {} | Bigrams: {} | Trigrams: {} | Hesitation: >{:.0}ms | Tri-gain: {}{}",
|
let mut line = String::new();
|
||||||
data.scope_label,
|
for seg in segments {
|
||||||
data.total_bigrams,
|
if line.len() + seg.len() <= w {
|
||||||
data.total_trigrams,
|
line.push_str(seg);
|
||||||
data.hesitation_threshold_ms,
|
} else {
|
||||||
gain_str,
|
break;
|
||||||
gain_note,
|
}
|
||||||
);
|
}
|
||||||
|
|
||||||
buf.set_string(
|
buf.set_string(
|
||||||
area.x,
|
area.x,
|
||||||
|
|||||||
@@ -81,6 +81,52 @@ impl AppLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub 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
|
||||||
|
}
|
||||||
|
|
||||||
pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
||||||
const MIN_POPUP_WIDTH: u16 = 72;
|
const MIN_POPUP_WIDTH: u16 = 72;
|
||||||
const MIN_POPUP_HEIGHT: u16 = 18;
|
const MIN_POPUP_HEIGHT: u16 = 18;
|
||||||
|
|||||||
Reference in New Issue
Block a user