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

@@ -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

View File

@@ -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

View File

@@ -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();

View File

@@ -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);
} }

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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;