Skip to main content

foch_cli/tui/browser/
app.rs

1use std::cell::Cell;
2use std::collections::BTreeSet;
3use std::num::NonZeroUsize;
4use std::path::PathBuf;
5use std::time::Instant;
6
7use crate::cli::arg::{DataBuildArgs, MergeArgs};
8use crate::cli::handler::input::{RepairTarget, repair_targets};
9
10use crossterm::event::{
11	KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
12};
13use foch::input::{CurrentEu4Input, DetectedPlaysetMod};
14use foch::merge::{
15	AnalyzedMerge, MergeAnalysisStatus, MergeDisposition, MergeProgress, MergeReviewSummary,
16	MergeUnitOutcome, default_merge_workers,
17};
18use ratatui::layout::{Position, Rect};
19
20const PAGE_STEP: usize = 10;
21
22/// Every disposition, in the order the summary and filter keys list them.
23pub const DISPOSITIONS: [MergeDisposition; 6] = [
24	MergeDisposition::Safe,
25	MergeDisposition::Copy,
26	MergeDisposition::NeedsUserChoice,
27	MergeDisposition::UnsupportedInput,
28	MergeDisposition::EngineFailure,
29	MergeDisposition::Deferred,
30];
31
32pub fn disposition_label(disposition: MergeDisposition) -> &'static str {
33	match disposition {
34		MergeDisposition::Safe => "safe",
35		MergeDisposition::Copy => "copy",
36		MergeDisposition::NeedsUserChoice => "needs_user_choice",
37		MergeDisposition::UnsupportedInput => "unsupported_input",
38		MergeDisposition::EngineFailure => "engine_failure",
39		MergeDisposition::Deferred => "deferred",
40	}
41}
42
43/// A finished analysis, detached from the frozen artifacts it was read from.
44#[derive(Clone, Debug)]
45pub struct AnalysisView {
46	pub status: MergeAnalysisStatus,
47	pub summary: MergeReviewSummary,
48	pub units: Vec<MergeUnitOutcome>,
49}
50
51impl AnalysisView {
52	pub fn from_analyzed(analyzed: &AnalyzedMerge) -> Self {
53		Self {
54			status: analyzed.analysis().status(),
55			summary: *analyzed.review_summary(),
56			units: analyzed.list_units().to_vec(),
57		}
58	}
59
60	pub fn count(&self, disposition: MergeDisposition) -> usize {
61		let summary = &self.summary;
62		match disposition {
63			MergeDisposition::Safe => summary.safe,
64			MergeDisposition::Copy => summary.copy,
65			MergeDisposition::NeedsUserChoice => summary.needs_user_choice,
66			MergeDisposition::UnsupportedInput => summary.unsupported_input,
67			MergeDisposition::EngineFailure => summary.engine_failure,
68			MergeDisposition::Deferred => summary.deferred,
69		}
70	}
71}
72
73/// The analysis switches the browser can change. They live only in memory
74/// and apply to the next analysis; nothing is saved.
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub struct AnalysisSettings {
77	/// Use the analyzed EU4 base as the merge ancestor (`--no-game-base`
78	/// turns this off).
79	pub game_base: bool,
80	/// `--gui-scroll-merge`.
81	pub gui_scroll_merge: bool,
82	/// `--ignore-replace-path`.
83	pub ignore_replace_path: bool,
84	/// `--force`: emit supported fallbacks for deferred conflicts.
85	pub force: bool,
86	pub merge_workers: NonZeroUsize,
87}
88
89impl Default for AnalysisSettings {
90	fn default() -> Self {
91		Self {
92			game_base: true,
93			gui_scroll_merge: false,
94			ignore_replace_path: false,
95			force: false,
96			merge_workers: default_merge_workers(),
97		}
98	}
99}
100
101/// Rows in the options panel, in [`AnalysisSettings`] field order.
102pub const OPTION_COUNT: usize = 5;
103const WORKERS_OPTION: usize = 4;
104
105impl AnalysisSettings {
106	fn toggle(&mut self, option: usize) {
107		match option {
108			0 => self.game_base = !self.game_base,
109			1 => self.gui_scroll_merge = !self.gui_scroll_merge,
110			2 => self.ignore_replace_path = !self.ignore_replace_path,
111			3 => self.force = !self.force,
112			_ => {}
113		}
114	}
115
116	fn adjust_workers(&mut self, more: bool) {
117		let workers = self.merge_workers.get();
118		let workers = if more {
119			workers.saturating_add(1)
120		} else {
121			workers - 1
122		};
123		self.merge_workers = NonZeroUsize::new(workers).unwrap_or(NonZeroUsize::MIN);
124	}
125}
126
127#[derive(Clone, Copy, Debug, Eq, PartialEq)]
128pub enum Screen {
129	Input,
130	Review,
131}
132
133#[derive(Clone, Copy, Debug, Eq, PartialEq)]
134pub enum Focus {
135	Units,
136	Detail,
137	Search,
138}
139
140/// The panels of the playset screen, in `Tab` order.
141#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
142pub enum InputPane {
143	#[default]
144	Mods,
145	Mod,
146	Issues,
147}
148
149impl InputPane {
150	fn next(self) -> Self {
151		match self {
152			Self::Mods => Self::Mod,
153			Self::Mod => Self::Issues,
154			Self::Issues => Self::Mods,
155		}
156	}
157
158	fn previous(self) -> Self {
159		match self {
160			Self::Mods => Self::Issues,
161			Self::Mod => Self::Mods,
162			Self::Issues => Self::Mod,
163		}
164	}
165}
166
167/// Where the last frame drew each panel, and the first row a scrolled list
168/// showed, so a mouse position maps back to a panel and a row.
169#[derive(Clone, Copy, Debug, Default)]
170pub struct HitAreas {
171	pub header: Rect,
172	pub banner: Rect,
173	pub mods: Rect,
174	pub mods_offset: usize,
175	pub selected_mod: Rect,
176	pub issues: Rect,
177	pub summary: Rect,
178	pub units: Rect,
179	pub units_offset: usize,
180	pub detail: Rect,
181}
182
183impl HitAreas {
184	/// The panels the current screen shows, for confining a text selection.
185	fn panels(&self, reviewing: bool) -> Vec<Rect> {
186		if reviewing {
187			vec![self.header, self.summary, self.units, self.detail]
188		} else {
189			vec![
190				self.header,
191				self.banner,
192				self.mods,
193				self.selected_mod,
194				self.issues,
195			]
196		}
197	}
198}
199
200/// The inside of a bordered panel.
201fn inner(area: Rect) -> Rect {
202	Rect::new(
203		area.x.saturating_add(1),
204		area.y.saturating_add(1),
205		area.width.saturating_sub(2),
206		area.height.saturating_sub(2),
207	)
208}
209
210/// `at` moved into `bounds`.
211fn clamp(at: Position, bounds: Rect) -> Position {
212	Position::new(
213		at.x.clamp(bounds.x, bounds.right().saturating_sub(1).max(bounds.x)),
214		at.y.clamp(bounds.y, bounds.bottom().saturating_sub(1).max(bounds.y)),
215	)
216}
217
218const WHEEL_STEP: usize = 3;
219
220#[derive(Clone, Debug)]
221pub enum Phase {
222	Inspecting,
223	Inspected,
224	Analyzing {
225		started: Instant,
226		progress: Option<MergeProgress>,
227	},
228	Reviewed(Box<AnalysisView>),
229	Failed(String),
230	/// Building EU4 base data from the game installation.
231	Building {
232		started: Instant,
233		/// The stage running now, by its name in [`BUILD_STAGES`].
234		stage: Option<String>,
235		/// How many stages have finished.
236		finished: usize,
237	},
238}
239
240/// What the session must do in response to a key.
241#[derive(Clone, Copy, Debug, Eq, PartialEq)]
242pub enum AppCommand {
243	Quit,
244	Analyze,
245	Refresh,
246	BuildBaseData,
247}
248
249/// Something outside the browser a key asked for, carried out by the run
250/// loop: none of these writes a file.
251#[derive(Clone, Debug, Eq, PartialEq)]
252pub enum Effect {
253	/// Put text on the system clipboard.
254	Copy(String),
255	/// Open these mods' Workshop pages in Steam, as
256	/// `foch input repair --open` does.
257	OpenWorkshop(Vec<RepairTarget>),
258	/// Capture the mouse, or release it to the terminal for its own text
259	/// selection.
260	MouseCapture(bool),
261}
262
263/// The stages of a base data build, as `foch data build` names them, with
264/// what each does.
265pub const BUILD_STAGES: [(&str, &str); 8] = [
266	("detect_version", "Detect the game version"),
267	("collect_inventory", "Collect game files"),
268	("discover_documents", "Find script documents"),
269	("parse_documents", "Parse every document"),
270	("build_semantic_index", "Index definitions and references"),
271	("materialize_snapshot", "Assemble the snapshot"),
272	("encode_snapshot", "Encode the snapshot"),
273	("write_outputs", "Install into Foch's data directory"),
274];
275
276/// Every key, with the `foch` command that does the same outside the
277/// browser, as the help panel lists them.
278pub const KEY_HELP: &[(&str, &str, &str)] = &[
279	(
280		"a",
281		"analyze the playset",
282		"foch merge --out <OUT> --non-interactive",
283	),
284	(
285		"x",
286		"exclude or include the selected mod",
287		"foch merge --exclude <MOD>",
288	),
289	(
290		"X",
291		"exclude every mod that cannot be analyzed",
292		"foch merge --exclude <MOD>...",
293	),
294	(
295		"R",
296		"repair every broken mod in Steam",
297		"foch input repair --open",
298	),
299	(
300		"w",
301		"repair the selected mod in Steam",
302		"foch input repair --open --mod <MOD>",
303	),
304	("r", "inspect the input again", "foch input inspect"),
305	(
306		"B",
307		"build EU4 base data from the game",
308		"foch data build eu4 --from-game-path <GAME> --install",
309	),
310	(
311		"o",
312		"analysis options",
313		"--no-game-base, --force, --jobs ...",
314	),
315	("0-6 /", "filter and search the review", ""),
316	("Tab", "next panel", ""),
317	("c", "copy the focused panel", ""),
318	("C", "copy the analysis command", ""),
319	("m", "release the mouse for terminal text selection", ""),
320	("i", "switch between playset and review", ""),
321	("?", "this help", ""),
322	("q", "quit", ""),
323];
324
325/// The browser's whole visible state. It only reads: no key leads to a
326/// decision, an artifact, or a write.
327#[derive(Debug)]
328pub struct App {
329	pub phase: Phase,
330	pub screen: Screen,
331	pub focus: Focus,
332	pub input: Option<CurrentEu4Input>,
333	/// Whether the current input snapshot can still be analyzed with some
334	/// selection of its mods. Each snapshot is analyzed at most once.
335	pub selectable: bool,
336	/// Mods, by playset id, the user left out of the next analysis. Kept
337	/// across refreshes; ids no longer in the playset are ignored.
338	pub excluded: BTreeSet<String>,
339	/// The INPUT_SOURCE `foch merge` takes for this input, or `None` for the
340	/// current EU4 playset.
341	pub source_path: Option<PathBuf>,
342	pub mod_scroll: usize,
343	pub filter: Option<MergeDisposition>,
344	pub query: String,
345	/// Index into [`App::visible_units`].
346	pub selected: usize,
347	pub detail_scroll: u16,
348	/// The focused panel of the playset screen.
349	pub input_pane: InputPane,
350	pub mod_detail_scroll: u16,
351	pub issues_scroll: u16,
352	/// Filled in by each frame; read by mouse handling.
353	pub hit: Cell<HitAreas>,
354	pub settings: AnalysisSettings,
355	/// The settings and exclusions of the analysis being run or browsed.
356	pub analyzed_with: Option<(AnalysisSettings, BTreeSet<String>)>,
357	/// Whether the input source can supply the EU4 base as an ancestor.
358	pub game_base_available: bool,
359	/// Cursor row while the options panel is open.
360	pub options: Option<usize>,
361	/// Asking whether to analyze without the excluded mods.
362	pub confirming_exclusions: bool,
363	/// Asking whether to build base data, which writes Foch's data directory.
364	pub confirming_build: bool,
365	/// Why the last key could not do what it asks, until the next key.
366	pub refusal: Option<String>,
367	/// What the last key did, until the next key.
368	pub notice: Option<String>,
369	/// Waiting for the run loop to carry it out.
370	pub effect: Option<Effect>,
371	pub mouse_capture: bool,
372	pub help: bool,
373	/// Where the left button went down, while it is held.
374	pub drag_anchor: Option<Position>,
375	/// The screen cells a drag covers, from where it started to where it
376	/// is; highlighted, and copied when the button is released.
377	pub selection: Option<(Position, Position)>,
378	/// The inside of the panel the drag started in: a selection never
379	/// leaves it, so it copies only that panel's text and no borders.
380	pub selection_bounds: Rect,
381	/// The finished selection is waiting to be copied from the next frame.
382	pub copy_selection: bool,
383	analyze_after_inspection: bool,
384}
385
386impl Default for App {
387	fn default() -> Self {
388		Self {
389			phase: Phase::Inspecting,
390			screen: Screen::Input,
391			focus: Focus::Units,
392			input: None,
393			selectable: false,
394			excluded: BTreeSet::new(),
395			source_path: None,
396			mod_scroll: 0,
397			filter: None,
398			query: String::new(),
399			selected: 0,
400			detail_scroll: 0,
401			input_pane: InputPane::Mods,
402			mod_detail_scroll: 0,
403			issues_scroll: 0,
404			hit: Cell::new(HitAreas::default()),
405			settings: AnalysisSettings::default(),
406			analyzed_with: None,
407			game_base_available: true,
408			options: None,
409			confirming_exclusions: false,
410			confirming_build: false,
411			refusal: None,
412			notice: None,
413			effect: None,
414			mouse_capture: true,
415			help: false,
416			drag_anchor: None,
417			selection: None,
418			selection_bounds: Rect::default(),
419			copy_selection: false,
420			analyze_after_inspection: false,
421		}
422	}
423}
424
425impl App {
426	pub fn analysis(&self) -> Option<&AnalysisView> {
427		match &self.phase {
428			Phase::Reviewed(view) => Some(view),
429			_ => None,
430		}
431	}
432
433	/// The settings an analysis started now would use.
434	pub fn effective_settings(&self) -> AnalysisSettings {
435		self.settings
436	}
437
438	/// Whether the options or exclusions changed since the browsed analysis
439	/// ran.
440	pub fn settings_changed(&self) -> bool {
441		self.analyzed_with
442			.as_ref()
443			.is_some_and(|(settings, excluded)| {
444				*settings != self.effective_settings() || *excluded != self.effective_exclusions()
445			})
446	}
447
448	pub fn mods(&self) -> &[DetectedPlaysetMod] {
449		self.input
450			.as_ref()
451			.and_then(|input| input.playset.as_ref())
452			.map_or(&[], |playset| &playset.mods)
453	}
454
455	pub fn is_excluded(&self, playset_mod: &DetectedPlaysetMod) -> bool {
456		self.excluded.contains(&playset_mod.id)
457	}
458
459	/// The exclusions that name a mod of the current playset.
460	pub fn effective_exclusions(&self) -> BTreeSet<String> {
461		self.mods()
462			.iter()
463			.filter(|playset_mod| self.is_excluded(playset_mod))
464			.map(|playset_mod| playset_mod.id.clone())
465			.collect()
466	}
467
468	/// Playset positions of the excluded mods, as input preparation takes
469	/// them.
470	pub fn excluded_positions(&self) -> BTreeSet<usize> {
471		self.mods()
472			.iter()
473			.filter(|playset_mod| self.is_excluded(playset_mod))
474			.map(|playset_mod| playset_mod.position)
475			.collect()
476	}
477
478	/// Mods that cannot be analyzed and are not excluded yet.
479	pub fn problem_mods(&self) -> Vec<&DetectedPlaysetMod> {
480		self.mods()
481			.iter()
482			.filter(|playset_mod| {
483				playset_mod.source_error.is_some() && !self.is_excluded(playset_mod)
484			})
485			.collect()
486	}
487
488	pub fn included_count(&self) -> usize {
489		self.mods()
490			.iter()
491			.filter(|playset_mod| !self.is_excluded(playset_mod))
492			.count()
493	}
494
495	/// Whether the analysis would use the game base while its base data is
496	/// not ready.
497	pub fn needs_base_data(&self) -> bool {
498		self.settings.game_base && !self.game_base_available
499	}
500
501	pub fn can_analyze(&self) -> bool {
502		self.selectable
503			&& !self.needs_base_data()
504			&& !self.is_working()
505			&& self.problem_mods().is_empty()
506			&& self.included_count() > 0
507	}
508
509	/// The `foch data build` arguments `B` runs: the inspected EU4
510	/// installation, installed into Foch's data directory.
511	pub fn data_build_args(&self) -> Option<DataBuildArgs> {
512		let game_root = self.input.as_ref()?.game.install_path.clone()?;
513		Some(DataBuildArgs {
514			game_name: "eu4".to_string(),
515			from_game_path: game_root,
516			game_version: "auto".to_string(),
517			install: true,
518			output_dir: None,
519			release_asset: false,
520			profile_out: None,
521		})
522	}
523
524	pub(super) fn building(&mut self) {
525		self.phase = Phase::Building {
526			started: Instant::now(),
527			stage: None,
528			finished: 0,
529		};
530	}
531
532	pub(super) fn build_stage(&mut self, name: String, done: bool) {
533		if let Phase::Building {
534			stage, finished, ..
535		} = &mut self.phase
536		{
537			if done {
538				*finished += 1;
539			}
540			*stage = Some(name);
541		}
542	}
543
544	pub(super) fn built(&mut self, result: Result<Vec<String>, String>) {
545		match result {
546			Ok(report) => {
547				self.notice = Some(format!("Base data built. {}", report.join(" ")));
548			}
549			Err(error) => {
550				self.refusal = Some(format!("Building base data failed: {error}"));
551			}
552		}
553	}
554
555	/// The `foch merge` arguments of the analysis `a` runs. The browser
556	/// analyzes through exactly these, writing nothing, so `cli_command`
557	/// reproduces its analysis.
558	pub fn merge_args(&self, out: PathBuf) -> MergeArgs {
559		let settings = self.effective_settings();
560		MergeArgs {
561			playset_path: self.source_path.clone(),
562			out,
563			force: settings.force,
564			no_game_base: !settings.game_base,
565			include_base: false,
566			exclude: self
567				.mods()
568				.iter()
569				.filter(|playset_mod| self.is_excluded(playset_mod))
570				.map(|playset_mod| playset_mod.id.clone())
571				.collect(),
572			gui_scroll_merge: settings.gui_scroll_merge,
573			ignore_replace_path: settings.ignore_replace_path,
574			ignore_dep: Vec::new(),
575			config: None,
576			provenance: false,
577			confirm: false,
578			non_interactive: true,
579			review_all: false,
580			cli_prompt: false,
581			jobs: (settings.merge_workers != default_merge_workers())
582				.then_some(settings.merge_workers),
583		}
584	}
585
586	/// The `foch` command that runs the analysis `a` would run, so a script
587	/// or agent can reproduce it without the browser.
588	pub fn cli_command(&self) -> String {
589		let args = self.merge_args(PathBuf::from("<OUT>")).command_line();
590		std::iter::once("foch".to_string())
591			.chain(args.into_iter().map(|arg| {
592				if arg.contains(char::is_whitespace) {
593					format!("\"{arg}\"")
594				} else {
595					arg
596				}
597			}))
598			.collect::<Vec<_>>()
599			.join(" ")
600	}
601
602	pub fn is_working(&self) -> bool {
603		matches!(
604			self.phase,
605			Phase::Inspecting | Phase::Analyzing { .. } | Phase::Building { .. }
606		)
607	}
608
609	/// Indices of the units that pass the disposition filter and the query,
610	/// in review order.
611	pub fn visible_units(&self) -> Vec<usize> {
612		let Some(view) = self.analysis() else {
613			return Vec::new();
614		};
615		let query = self.query.to_lowercase();
616		view.units
617			.iter()
618			.enumerate()
619			.filter(|(_, unit)| self.filter.is_none_or(|filter| unit.disposition == filter))
620			.filter(|(_, unit)| {
621				query.is_empty()
622					|| unit.id.to_lowercase().contains(&query)
623					|| unit.family.to_lowercase().contains(&query)
624			})
625			.map(|(index, _)| index)
626			.collect()
627	}
628
629	pub fn selected_unit(&self) -> Option<&MergeUnitOutcome> {
630		let index = *self.visible_units().get(self.selected)?;
631		self.analysis()?.units.get(index)
632	}
633
634	pub(super) fn inspecting(&mut self, analyze_after: bool) {
635		self.phase = Phase::Inspecting;
636		self.confirming_exclusions = false;
637		self.selectable = false;
638		self.analyze_after_inspection = analyze_after;
639		self.focus = Focus::Units;
640	}
641
642	pub(super) fn inspected(
643		&mut self,
644		input: CurrentEu4Input,
645		selectable: bool,
646		game_base_available: bool,
647	) {
648		self.input = Some(input);
649		self.source_path = None;
650		self.game_base_available = game_base_available;
651		self.selectable = selectable;
652		self.mod_detail_scroll = 0;
653		self.issues_scroll = 0;
654		self.phase = Phase::Inspected;
655		self.screen = Screen::Input;
656		self.mod_scroll = 0;
657	}
658
659	pub(super) fn take_analyze_after_inspection(&mut self) -> bool {
660		if !std::mem::take(&mut self.analyze_after_inspection) || !self.can_analyze() {
661			return false;
662		}
663		if !self.effective_exclusions().is_empty() {
664			// A refresh never leaves mods out on its own; ask again.
665			self.confirming_exclusions = true;
666			return false;
667		}
668		true
669	}
670
671	pub(super) fn analyzing(&mut self) {
672		self.selectable = false;
673		self.analyzed_with = Some((self.effective_settings(), self.effective_exclusions()));
674		self.phase = Phase::Analyzing {
675			started: Instant::now(),
676			progress: None,
677		};
678	}
679
680	pub(super) fn progress(&mut self, update: MergeProgress) {
681		if let Phase::Analyzing { progress, .. } = &mut self.phase {
682			*progress = Some(update);
683		}
684	}
685
686	pub(super) fn analyzed(&mut self, result: Result<AnalysisView, String>) {
687		match result {
688			Ok(view) => {
689				self.phase = Phase::Reviewed(Box::new(view));
690				self.screen = Screen::Review;
691				self.focus = Focus::Units;
692				self.selected = 0;
693				self.detail_scroll = 0;
694			}
695			Err(error) => self.phase = Phase::Failed(error),
696		}
697	}
698
699	pub fn handle_key(&mut self, key: KeyEvent) -> Option<AppCommand> {
700		if key.kind == KeyEventKind::Release {
701			return None;
702		}
703		if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
704			return Some(AppCommand::Quit);
705		}
706		self.refusal = None;
707		self.notice = None;
708		self.selection = None;
709		if self.help {
710			self.help = false;
711			return (key.code == KeyCode::Char('q')).then_some(AppCommand::Quit);
712		}
713		if self.focus == Focus::Search {
714			self.handle_search_key(key);
715			return None;
716		}
717		if let Some(cursor) = self.options {
718			return self.handle_options_key(key, cursor);
719		}
720		if self.confirming_build {
721			self.confirming_build = false;
722			return match key.code {
723				KeyCode::Char('q') => Some(AppCommand::Quit),
724				KeyCode::Char('y') => Some(AppCommand::BuildBaseData),
725				_ => None,
726			};
727		}
728		if self.confirming_exclusions {
729			return match key.code {
730				KeyCode::Char('q') => Some(AppCommand::Quit),
731				KeyCode::Char('y') => {
732					self.confirming_exclusions = false;
733					Some(AppCommand::Analyze)
734				}
735				KeyCode::Char('n') | KeyCode::Esc => {
736					self.confirming_exclusions = false;
737					None
738				}
739				_ => None,
740			};
741		}
742		match key.code {
743			KeyCode::Char('q') => return Some(AppCommand::Quit),
744			KeyCode::Char('o') => {
745				self.options = Some(0);
746				return None;
747			}
748			KeyCode::Char('?') => {
749				self.help = true;
750				return None;
751			}
752			KeyCode::Char('c') => {
753				let text = self.focused_text();
754				self.notice = Some(format!("Copied {} characters.", text.chars().count()));
755				self.effect = Some(Effect::Copy(text));
756				return None;
757			}
758			KeyCode::Char('C') => {
759				self.notice = Some("Copied the analysis command.".to_string());
760				self.effect = Some(Effect::Copy(self.cli_command()));
761				return None;
762			}
763			KeyCode::Char('m') => {
764				self.mouse_capture = !self.mouse_capture;
765				self.notice = Some(
766					if self.mouse_capture {
767						"Mouse captured: click panels and rows, scroll with the wheel."
768					} else {
769						"Mouse released: select text with the terminal; press m to capture it again."
770					}
771					.to_string(),
772				);
773				self.effect = Some(Effect::MouseCapture(self.mouse_capture));
774				return None;
775			}
776			KeyCode::Char('R') => {
777				self.repair(Vec::new());
778				return None;
779			}
780			KeyCode::Char('B') => {
781				match (self.is_working(), self.data_build_args()) {
782					(true, _) => {
783						self.refusal = Some("Wait for the current work to finish.".to_string())
784					}
785					(false, None) => {
786						self.refusal = Some(
787							"The EU4 installation was not found, so there is nothing to build base data from."
788								.to_string(),
789						);
790					}
791					(false, Some(_)) => self.confirming_build = true,
792				}
793				return None;
794			}
795			KeyCode::Char('w') => {
796				if let Some(id) = self.mods().get(self.mod_scroll).map(|m| m.id.clone()) {
797					self.repair(vec![id]);
798				}
799				return None;
800			}
801			KeyCode::Char('r') if !self.is_working() => return Some(AppCommand::Refresh),
802			KeyCode::Char('a') if self.can_analyze() => {
803				if !self.effective_exclusions().is_empty() {
804					self.confirming_exclusions = true;
805					return None;
806				}
807				return Some(AppCommand::Analyze);
808			}
809			KeyCode::Char('a') => {
810				self.refusal = Some(self.analyze_refusal());
811				return None;
812			}
813			KeyCode::Char('i') | KeyCode::Char('p') if self.analysis().is_some() => {
814				self.screen = match self.screen {
815					Screen::Input => Screen::Review,
816					Screen::Review => Screen::Input,
817				};
818				return None;
819			}
820			_ => {}
821		}
822		match self.screen {
823			Screen::Input => self.handle_input_key(key),
824			Screen::Review => self.handle_review_key(key),
825		}
826		None
827	}
828
829	/// Ask Steam to show the named mods, or every mod that cannot be
830	/// analyzed, through the same targets `foch input repair` lists.
831	fn repair(&mut self, named: Vec<String>) {
832		let Some(input) = &self.input else {
833			return;
834		};
835		let command = if named.is_empty() {
836			"foch input repair --open".to_string()
837		} else {
838			format!("foch input repair --open --mod {}", named.join(" --mod "))
839		};
840		match repair_targets(input, &named) {
841			Ok(targets) if targets.iter().all(|target| target.workshop_id.is_none()) => {
842				self.refusal = Some(if targets.is_empty() {
843					"Every mod can be analyzed; nothing to repair.".to_string()
844				} else {
845					"These mods are not Workshop items; repair them outside Steam.".to_string()
846				});
847			}
848			Ok(targets) => {
849				self.notice = Some(format!(
850					"Opening {} Workshop page(s) in Steam: unsubscribe and subscribe again, wait for the download, then press r.   CLI: {command}",
851					targets
852						.iter()
853						.filter(|target| target.workshop_id.is_some())
854						.count()
855				));
856				self.effect = Some(Effect::OpenWorkshop(targets));
857			}
858			Err(error) => self.refusal = Some(error),
859		}
860	}
861
862	/// The text of the focused panel, for `c`.
863	pub fn focused_text(&self) -> String {
864		if self.analysis().is_some() && self.screen == Screen::Review {
865			let Some(unit) = self.selected_unit() else {
866				return String::new();
867			};
868			return match self.focus {
869				Focus::Detail => super::render::unit_detail(unit)
870					.lines
871					.iter()
872					.map(|line| {
873						line.spans
874							.iter()
875							.map(|span| span.content.as_ref())
876							.collect::<String>()
877					})
878					.collect::<Vec<_>>()
879					.join("\n"),
880				_ => unit.path.as_str().to_owned(),
881			};
882		}
883		let selected = self.mods().get(self.mod_scroll);
884		match self.input_pane {
885			InputPane::Mods => selected
886				.map(|m| format!("#{} {} {}", m.position, m.id, m.name))
887				.unwrap_or_default(),
888			InputPane::Mod => selected
889				.map(|m| {
890					let mut lines = vec![m.name.clone(), format!("#{} {}", m.position, m.id)];
891					if let Some(version) = &m.version {
892						lines.push(format!("version {version}"));
893					}
894					if !m.declared_dependencies.is_empty() {
895						lines.push(format!(
896							"depends on: {}",
897							m.declared_dependencies.join(", ")
898						));
899					}
900					if let Some(error) = &m.source_error {
901						lines.push(error.clone());
902					}
903					lines.join("\n")
904				})
905				.unwrap_or_default(),
906			InputPane::Issues => self
907				.input
908				.iter()
909				.flat_map(|input| &input.issues)
910				.map(|issue| {
911					let mut text = format!("{}: {}", issue.title, issue.detail);
912					if let Some(action) = &issue.action {
913						text.push_str(&format!("\n  -> {action}"));
914					}
915					text
916				})
917				.collect::<Vec<_>>()
918				.join("\n"),
919		}
920	}
921
922	/// Why `a` cannot start an analysis right now.
923	fn analyze_refusal(&self) -> String {
924		let problems = self.problem_mods().len();
925		match self.phase {
926			Phase::Inspecting => {
927				"Still inspecting the input; analysis can start when it finishes.".to_string()
928			}
929			Phase::Building { .. } => {
930				"Base data is being built; analysis can start when it finishes.".to_string()
931			}
932			Phase::Analyzing { .. } => "An analysis is already running.".to_string(),
933			Phase::Reviewed(_) | Phase::Failed(_) => {
934				"This input snapshot was already analyzed. Press r to re-inspect and analyze again."
935					.to_string()
936			}
937			Phase::Inspected if self.selectable && self.needs_base_data() => {
938				"The EU4 base data is not ready. Press B to build it here, or press o and turn off the EU4 base as ancestor (--no-game-base)."
939					.to_string()
940			}
941			Phase::Inspected if !self.selectable => {
942				"Cannot analyze: the issues listed under Issues block this input. Fix them, then press r."
943					.to_string()
944			}
945			Phase::Inspected if problems > 0 => format!(
946				"{problems} mod{} cannot be analyzed. Press X to exclude {}, or x on a selected mod.",
947				if problems == 1 { "" } else { "s" },
948				if problems == 1 { "it" } else { "them all" }
949			),
950			Phase::Inspected => "Every mod is excluded; press x to include one again.".to_string(),
951		}
952	}
953
954	fn handle_options_key(&mut self, key: KeyEvent, cursor: usize) -> Option<AppCommand> {
955		match key.code {
956			KeyCode::Char('q') => return Some(AppCommand::Quit),
957			KeyCode::Esc | KeyCode::Char('o') => self.options = None,
958			KeyCode::Up | KeyCode::Char('k') => self.options = Some(cursor.saturating_sub(1)),
959			KeyCode::Down | KeyCode::Char('j') => {
960				self.options = Some((cursor + 1).min(OPTION_COUNT - 1));
961			}
962			KeyCode::Left | KeyCode::Char('-') if cursor == WORKERS_OPTION => {
963				self.settings.adjust_workers(false);
964			}
965			KeyCode::Right | KeyCode::Char('+') if cursor == WORKERS_OPTION => {
966				self.settings.adjust_workers(true);
967			}
968			KeyCode::Enter | KeyCode::Char(' ') => self.settings.toggle(cursor),
969			_ => {}
970		}
971		None
972	}
973
974	fn handle_input_key(&mut self, key: KeyEvent) {
975		match key.code {
976			KeyCode::Tab => {
977				self.input_pane = self.input_pane.next();
978				return;
979			}
980			KeyCode::BackTab => {
981				self.input_pane = self.input_pane.previous();
982				return;
983			}
984			_ => {}
985		}
986		match key.code {
987			KeyCode::Char('x') => {
988				if let Some(id) = self.mods().get(self.mod_scroll).map(|m| m.id.clone())
989					&& !self.excluded.remove(&id)
990				{
991					self.excluded.insert(id);
992				}
993				return;
994			}
995			KeyCode::Char('X') => {
996				let problems = self
997					.problem_mods()
998					.into_iter()
999					.map(|playset_mod| playset_mod.id.clone())
1000					.collect::<Vec<_>>();
1001				self.excluded.extend(problems);
1002				return;
1003			}
1004			_ => {}
1005		}
1006		match self.input_pane {
1007			InputPane::Mods => {
1008				let last = self.mods().len().saturating_sub(1);
1009				self.select_mod(step(self.mod_scroll, key.code, last));
1010			}
1011			InputPane::Mod => {
1012				self.mod_detail_scroll =
1013					step(self.mod_detail_scroll as usize, key.code, u16::MAX as usize) as u16;
1014			}
1015			InputPane::Issues => {
1016				self.issues_scroll =
1017					step(self.issues_scroll as usize, key.code, u16::MAX as usize) as u16;
1018			}
1019		}
1020		if key.code == KeyCode::Esc && self.analysis().is_some() {
1021			self.screen = Screen::Review;
1022		}
1023	}
1024
1025	fn select_mod(&mut self, index: usize) {
1026		if index != self.mod_scroll {
1027			self.mod_scroll = index;
1028			self.mod_detail_scroll = 0;
1029		}
1030	}
1031
1032	fn select_unit(&mut self, index: usize) {
1033		if index != self.selected {
1034			self.selected = index;
1035			self.detail_scroll = 0;
1036		}
1037	}
1038
1039	/// A click focuses the panel under the pointer and, in a list, selects
1040	/// the row; the wheel scrolls the panel under the pointer. Mouse input
1041	/// is ignored while a dialog is open.
1042	pub fn handle_mouse(&mut self, mouse: MouseEvent) {
1043		if self.options.is_some()
1044			|| self.confirming_exclusions
1045			|| self.help
1046			|| self.focus == Focus::Search
1047		{
1048			return;
1049		}
1050		let at = Position::new(mouse.column, mouse.row);
1051		// Dragging selects screen text to copy, as in a plain terminal; a
1052		// click without a drag focuses and selects.
1053		match mouse.kind {
1054			MouseEventKind::Down(MouseButton::Left) => {
1055				let reviewing = self.analysis().is_some() && self.screen == Screen::Review;
1056				self.selection_bounds = self
1057					.hit
1058					.get()
1059					.panels(reviewing)
1060					.into_iter()
1061					.find(|panel| panel.contains(at))
1062					.map(inner)
1063					.unwrap_or(Rect::new(0, at.y, u16::MAX, 1));
1064				self.drag_anchor = Some(clamp(at, self.selection_bounds));
1065				self.selection = None;
1066			}
1067			MouseEventKind::Drag(MouseButton::Left) => {
1068				if let Some(anchor) = self.drag_anchor {
1069					self.selection = Some((anchor, clamp(at, self.selection_bounds)));
1070				}
1071				return;
1072			}
1073			MouseEventKind::Up(MouseButton::Left) => {
1074				self.drag_anchor = None;
1075				if self.selection.is_some_and(|(start, end)| start != end) {
1076					self.copy_selection = true;
1077				} else {
1078					self.selection = None;
1079				}
1080				return;
1081			}
1082			_ => {}
1083		}
1084		let hit = self.hit.get();
1085		let down = match mouse.kind {
1086			MouseEventKind::ScrollDown => Some(true),
1087			MouseEventKind::ScrollUp => Some(false),
1088			MouseEventKind::Down(_) => None,
1089			_ => return,
1090		};
1091		let scroll = |value: usize, last: usize| match down {
1092			Some(true) => value.saturating_add(WHEEL_STEP).min(last),
1093			Some(false) => value.saturating_sub(WHEEL_STEP),
1094			None => value,
1095		};
1096		// The row a click landed on, below a panel's border and any header.
1097		let row = |area: Rect, offset: usize, header: u16| {
1098			let first = area.y + 1 + header;
1099			(at.y >= first && at.y < area.bottom().saturating_sub(1))
1100				.then(|| offset + usize::from(at.y - first))
1101		};
1102		let reviewing = self.analysis().is_some() && self.screen == Screen::Review;
1103		if reviewing {
1104			if hit.units.contains(at) {
1105				self.focus = Focus::Units;
1106				let last = self.visible_units().len().saturating_sub(1);
1107				match (down, row(hit.units, hit.units_offset, 0)) {
1108					(Some(_), _) => self.select_unit(scroll(self.selected, last)),
1109					(None, Some(index)) if index <= last => self.select_unit(index),
1110					_ => {}
1111				}
1112			} else if hit.detail.contains(at) && self.selected_unit().is_some() {
1113				self.focus = Focus::Detail;
1114				self.detail_scroll = scroll(self.detail_scroll as usize, u16::MAX as usize) as u16;
1115			}
1116			return;
1117		}
1118		if hit.mods.contains(at) {
1119			self.input_pane = InputPane::Mods;
1120			let last = self.mods().len().saturating_sub(1);
1121			match (down, row(hit.mods, hit.mods_offset, 1)) {
1122				(Some(_), _) => self.select_mod(scroll(self.mod_scroll, last)),
1123				(None, Some(index)) if index <= last && !self.mods().is_empty() => {
1124					self.select_mod(index);
1125				}
1126				_ => {}
1127			}
1128		} else if hit.selected_mod.contains(at) {
1129			self.input_pane = InputPane::Mod;
1130			self.mod_detail_scroll =
1131				scroll(self.mod_detail_scroll as usize, u16::MAX as usize) as u16;
1132		} else if hit.issues.contains(at) {
1133			self.input_pane = InputPane::Issues;
1134			self.issues_scroll = scroll(self.issues_scroll as usize, u16::MAX as usize) as u16;
1135		}
1136	}
1137
1138	fn handle_review_key(&mut self, key: KeyEvent) {
1139		if let KeyCode::Char(digit @ '0'..='6') = key.code {
1140			let filter = match digit {
1141				'0' => None,
1142				digit => Some(DISPOSITIONS[digit as usize - '1' as usize]),
1143			};
1144			self.set_filter(filter);
1145			return;
1146		}
1147		match (self.focus, key.code) {
1148			(_, KeyCode::Char('/')) => self.focus = Focus::Search,
1149			(Focus::Units, KeyCode::Enter | KeyCode::Right | KeyCode::Tab | KeyCode::BackTab) => {
1150				if self.selected_unit().is_some() {
1151					self.focus = Focus::Detail;
1152				}
1153			}
1154			(Focus::Detail, KeyCode::Esc | KeyCode::Left | KeyCode::Tab | KeyCode::BackTab) => {
1155				self.focus = Focus::Units;
1156			}
1157			(Focus::Detail, code) => {
1158				self.detail_scroll =
1159					step(self.detail_scroll as usize, code, u16::MAX as usize) as u16;
1160			}
1161			(Focus::Units, KeyCode::Esc) if !self.query.is_empty() => self.set_query(String::new()),
1162			(Focus::Units, code) => {
1163				let last = self.visible_units().len().saturating_sub(1);
1164				self.select_unit(step(self.selected, code, last));
1165			}
1166			(Focus::Search, _) => unreachable!("search keys are handled first"),
1167		}
1168	}
1169
1170	fn handle_search_key(&mut self, key: KeyEvent) {
1171		match key.code {
1172			KeyCode::Enter => self.focus = Focus::Units,
1173			KeyCode::Esc => {
1174				self.focus = Focus::Units;
1175				self.set_query(String::new());
1176			}
1177			KeyCode::Backspace => {
1178				let mut query = self.query.clone();
1179				query.pop();
1180				self.set_query(query);
1181			}
1182			KeyCode::Char(value) => {
1183				let mut query = self.query.clone();
1184				query.push(value);
1185				self.set_query(query);
1186			}
1187			_ => {}
1188		}
1189	}
1190
1191	fn set_filter(&mut self, filter: Option<MergeDisposition>) {
1192		self.reselect(|app| app.filter = filter);
1193	}
1194
1195	fn set_query(&mut self, query: String) {
1196		self.reselect(|app| app.query = query);
1197	}
1198
1199	/// Change what is visible while keeping the selected unit selected when
1200	/// it is still visible.
1201	fn reselect(&mut self, change: impl FnOnce(&mut Self)) {
1202		let selected_id = self.selected_unit().map(|unit| unit.id.clone());
1203		change(self);
1204		let visible = self.visible_units();
1205		self.selected = selected_id
1206			.and_then(|id| {
1207				let units = &self.analysis()?.units;
1208				visible.iter().position(|index| units[*index].id == id)
1209			})
1210			.unwrap_or(0);
1211		self.detail_scroll = 0;
1212	}
1213}
1214
1215fn step(current: usize, code: KeyCode, last: usize) -> usize {
1216	match code {
1217		KeyCode::Up | KeyCode::Char('k') => current.saturating_sub(1),
1218		KeyCode::Down | KeyCode::Char('j') => current.saturating_add(1).min(last),
1219		KeyCode::PageUp => current.saturating_sub(PAGE_STEP),
1220		KeyCode::PageDown => current.saturating_add(PAGE_STEP).min(last),
1221		KeyCode::Home | KeyCode::Char('g') => 0,
1222		KeyCode::End | KeyCode::Char('G') => last,
1223		_ => current,
1224	}
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229	use super::*;
1230	use serde_json::json;
1231
1232	/// Mod 2 cannot be analyzed; mod 1 can.
1233	fn input_with_a_broken_mod() -> CurrentEu4Input {
1234		let playset_mod = |position: usize, id: &str, error: Option<&str>| {
1235			json!({
1236				"id": id, "name": format!("Mod {id}"), "position": position, "enabled": true,
1237				"workshopId": id, "workshopManifestId": null, "version": null,
1238				"declaredDependencies": [], "descriptorPath": null, "sourceError": error
1239			})
1240		};
1241		serde_json::from_value(json!({
1242			"readiness": "blocked",
1243			"game": { "name": "Europa Universalis IV", "version": "1.37.5", "installPath": null },
1244			"baseData": { "state": "ready", "version": "1.37.5", "detail": "ready" },
1245			"playset": {
1246				"name": "Current EU4 playset",
1247				"sourcePath": "dlc_load.json",
1248				"mods": [playset_mod(1, "41", None), playset_mod(2, "43", Some("missing"))]
1249			},
1250			"issues": [],
1251			"recovery": null
1252		}))
1253		.expect("input view")
1254	}
1255
1256	fn press(app: &mut App, code: KeyCode) -> Option<AppCommand> {
1257		app.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
1258	}
1259
1260	#[test]
1261	fn a_broken_mod_must_be_excluded_and_the_exclusion_confirmed() {
1262		let mut app = App::default();
1263		app.inspected(input_with_a_broken_mod(), true, true);
1264
1265		assert!(!app.can_analyze());
1266		assert_eq!(press(&mut app, KeyCode::Char('a')), None);
1267		assert!(
1268			app.refusal
1269				.as_deref()
1270				.is_some_and(|refusal| refusal.contains("Press X"))
1271		);
1272
1273		press(&mut app, KeyCode::Char('X'));
1274		assert_eq!(app.excluded_positions(), BTreeSet::from([2]));
1275		assert!(app.can_analyze());
1276		assert!(app.cli_command().ends_with("--exclude 43"));
1277
1278		assert_eq!(press(&mut app, KeyCode::Char('a')), None);
1279		assert!(app.confirming_exclusions);
1280		assert_eq!(press(&mut app, KeyCode::Esc), None);
1281		assert!(!app.confirming_exclusions);
1282		press(&mut app, KeyCode::Char('a'));
1283		assert_eq!(
1284			press(&mut app, KeyCode::Char('y')),
1285			Some(AppCommand::Analyze)
1286		);
1287	}
1288
1289	/// The command the browser shows parses, through the CLI's own parser,
1290	/// back to exactly the arguments the browser analyzes with.
1291	#[test]
1292	fn the_shown_command_is_the_analysis_the_browser_runs() {
1293		use crate::cli::arg::{FochCli, FochCliCommands};
1294		use clap::Parser;
1295
1296		let mut app = App::default();
1297		app.inspected(input_with_a_broken_mod(), true, true);
1298		app.excluded.insert("43".to_string());
1299		app.settings.game_base = false;
1300		app.settings.gui_scroll_merge = true;
1301		app.settings.ignore_replace_path = true;
1302		app.settings.force = true;
1303		app.settings.merge_workers = NonZeroUsize::new(3).expect("non-zero");
1304		for source_path in [None, Some(PathBuf::from("dir with space/dlc_load.json"))] {
1305			app.source_path = source_path;
1306			let expected = app.merge_args(PathBuf::from("<OUT>"));
1307			let argv = std::iter::once("foch".to_string()).chain(expected.command_line());
1308			let parsed = FochCli::try_parse_from(argv).expect("shown command parses");
1309			let Some(FochCliCommands::Merge(parsed)) = parsed.command else {
1310				panic!("shown command is not `foch merge`");
1311			};
1312			assert_eq!(parsed, expected);
1313		}
1314		assert!(
1315			app.cli_command()
1316				.starts_with("foch merge \"dir with space/dlc_load.json\"")
1317		);
1318	}
1319
1320	fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent {
1321		MouseEvent {
1322			kind,
1323			column,
1324			row,
1325			modifiers: KeyModifiers::NONE,
1326		}
1327	}
1328
1329	#[test]
1330	fn tab_moves_the_keyboard_between_playset_panels() {
1331		let mut app = App::default();
1332		app.inspected(input_with_a_broken_mod(), true, true);
1333
1334		assert_eq!(app.input_pane, InputPane::Mods);
1335		press(&mut app, KeyCode::Down);
1336		assert_eq!(app.mod_scroll, 1);
1337		press(&mut app, KeyCode::Tab);
1338		press(&mut app, KeyCode::Tab);
1339		assert_eq!(app.input_pane, InputPane::Issues);
1340		press(&mut app, KeyCode::Down);
1341		assert_eq!(app.issues_scroll, 1);
1342		assert_eq!(app.mod_scroll, 1, "only the focused panel moves");
1343		press(&mut app, KeyCode::BackTab);
1344		assert_eq!(app.input_pane, InputPane::Mod);
1345	}
1346
1347	#[test]
1348	fn a_click_focuses_a_panel_and_selects_its_row() {
1349		use crossterm::event::MouseButton;
1350
1351		let mut app = App::default();
1352		app.inspected(input_with_a_broken_mod(), true, true);
1353		app.hit.set(HitAreas {
1354			mods: Rect::new(0, 10, 40, 10),
1355			selected_mod: Rect::new(40, 10, 40, 5),
1356			issues: Rect::new(40, 15, 40, 5),
1357			..HitAreas::default()
1358		});
1359
1360		// Border at y=10, header at y=11, first mod at y=12.
1361		app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 5, 13));
1362		assert_eq!(app.mod_scroll, 1);
1363		app.handle_mouse(mouse(MouseEventKind::ScrollDown, 50, 17));
1364		assert_eq!(app.input_pane, InputPane::Issues);
1365		assert_eq!(app.issues_scroll, 3);
1366		app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 50, 12));
1367		assert_eq!(app.input_pane, InputPane::Mod);
1368	}
1369
1370	#[test]
1371	fn b_builds_base_data_with_the_command_it_shows_after_consent() {
1372		use crate::cli::arg::{FochCli, FochCliCommands, FochCliDataCommands};
1373		use clap::Parser;
1374
1375		let mut app = App::default();
1376		app.inspected(input_with_a_broken_mod(), true, false);
1377		assert_eq!(press(&mut app, KeyCode::Char('B')), None);
1378		assert!(
1379			app.refusal
1380				.as_deref()
1381				.is_some_and(|refusal| refusal.contains("not found"))
1382		);
1383
1384		let mut input = input_with_a_broken_mod();
1385		input.game.install_path = Some(PathBuf::from("G:/Steam Library/EU4"));
1386		app.inspected(input, true, false);
1387		assert_eq!(press(&mut app, KeyCode::Char('B')), None);
1388		assert!(app.confirming_build);
1389		assert_eq!(
1390			press(&mut app, KeyCode::Char('y')),
1391			Some(AppCommand::BuildBaseData)
1392		);
1393
1394		let expected = app.data_build_args().expect("build arguments");
1395		let argv = std::iter::once("foch".to_string()).chain(expected.command_line());
1396		let parsed = FochCli::try_parse_from(argv).expect("shown command parses");
1397		let Some(FochCliCommands::Data(data)) = parsed.command else {
1398			panic!("shown command is not `foch data`");
1399		};
1400		let FochCliDataCommands::Build(parsed) = data.command else {
1401			panic!("shown command is not `foch data build`");
1402		};
1403		assert_eq!(parsed, expected);
1404	}
1405
1406	#[test]
1407	fn missing_base_data_offers_b_in_the_footer_and_the_build_shows_its_stages() {
1408		use ratatui::Terminal;
1409		use ratatui::backend::TestBackend;
1410
1411		let mut input = input_with_a_broken_mod();
1412		input.game.install_path = Some(PathBuf::from("G:/EU4"));
1413		let mut app = App::default();
1414		app.inspected(input, true, false);
1415		let screen = |app: &App| {
1416			let mut terminal = Terminal::new(TestBackend::new(160, 40)).expect("terminal");
1417			terminal
1418				.draw(|frame| super::super::render::draw(frame, app))
1419				.expect("draw");
1420			let buffer = terminal.backend().buffer().clone();
1421			(0..buffer.area.height)
1422				.map(|y| {
1423					(0..buffer.area.width)
1424						.map(|x| buffer[(x, y)].symbol())
1425						.collect::<String>()
1426				})
1427				.collect::<Vec<_>>()
1428				.join(
1429					"
1430",
1431				)
1432		};
1433		assert!(screen(&app).contains(" B  build base data"));
1434
1435		app.building();
1436		app.build_stage("detect_version".to_string(), false);
1437		app.build_stage("detect_version".to_string(), true);
1438		app.build_stage("collect_inventory".to_string(), false);
1439		let shown = screen(&app);
1440		assert!(shown.contains("stage 2/8"), "{shown}");
1441		assert!(shown.contains("✔ Detect the game version"), "{shown}");
1442	}
1443
1444	#[test]
1445	fn x_toggles_any_selected_mod() {
1446		let mut app = App::default();
1447		app.inspected(input_with_a_broken_mod(), true, true);
1448
1449		press(&mut app, KeyCode::Char('x'));
1450		assert_eq!(app.excluded_positions(), BTreeSet::from([1]));
1451		press(&mut app, KeyCode::Char('x'));
1452		assert!(app.excluded_positions().is_empty());
1453	}
1454
1455	#[test]
1456	fn a_blocked_analyze_key_says_why_until_the_next_key() {
1457		let mut app = App::default();
1458		app.inspected(input_with_a_broken_mod(), false, true);
1459
1460		assert_eq!(press(&mut app, KeyCode::Char('a')), None);
1461		assert!(
1462			app.refusal
1463				.as_deref()
1464				.is_some_and(|refusal| refusal.contains("press r"))
1465		);
1466		press(&mut app, KeyCode::Down);
1467		assert!(app.refusal.is_none());
1468	}
1469
1470	#[test]
1471	fn a_refresh_asks_again_before_leaving_mods_out() {
1472		let mut app = App::default();
1473		app.excluded.insert("43".to_string());
1474		app.inspecting(true);
1475		app.inspected(input_with_a_broken_mod(), true, true);
1476
1477		assert!(!app.take_analyze_after_inspection());
1478		assert!(app.confirming_exclusions);
1479	}
1480}