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			review_json: None,
584		}
585	}
586
587	/// The `foch` command that runs the analysis `a` would run, so a script
588	/// or agent can reproduce it without the browser.
589	pub fn cli_command(&self) -> String {
590		let args = self.merge_args(PathBuf::from("<OUT>")).command_line();
591		std::iter::once("foch".to_string())
592			.chain(args.into_iter().map(|arg| {
593				if arg.contains(char::is_whitespace) {
594					format!("\"{arg}\"")
595				} else {
596					arg
597				}
598			}))
599			.collect::<Vec<_>>()
600			.join(" ")
601	}
602
603	pub fn is_working(&self) -> bool {
604		matches!(
605			self.phase,
606			Phase::Inspecting | Phase::Analyzing { .. } | Phase::Building { .. }
607		)
608	}
609
610	/// Indices of the units that pass the disposition filter and the query,
611	/// in review order.
612	pub fn visible_units(&self) -> Vec<usize> {
613		let Some(view) = self.analysis() else {
614			return Vec::new();
615		};
616		let query = self.query.to_lowercase();
617		view.units
618			.iter()
619			.enumerate()
620			.filter(|(_, unit)| self.filter.is_none_or(|filter| unit.disposition == filter))
621			.filter(|(_, unit)| {
622				query.is_empty()
623					|| unit.id.to_lowercase().contains(&query)
624					|| unit.family.to_lowercase().contains(&query)
625			})
626			.map(|(index, _)| index)
627			.collect()
628	}
629
630	pub fn selected_unit(&self) -> Option<&MergeUnitOutcome> {
631		let index = *self.visible_units().get(self.selected)?;
632		self.analysis()?.units.get(index)
633	}
634
635	pub(super) fn inspecting(&mut self, analyze_after: bool) {
636		self.phase = Phase::Inspecting;
637		self.confirming_exclusions = false;
638		self.selectable = false;
639		self.analyze_after_inspection = analyze_after;
640		self.focus = Focus::Units;
641	}
642
643	pub(super) fn inspected(
644		&mut self,
645		input: CurrentEu4Input,
646		selectable: bool,
647		game_base_available: bool,
648	) {
649		self.input = Some(input);
650		self.source_path = None;
651		self.game_base_available = game_base_available;
652		self.selectable = selectable;
653		self.mod_detail_scroll = 0;
654		self.issues_scroll = 0;
655		self.phase = Phase::Inspected;
656		self.screen = Screen::Input;
657		self.mod_scroll = 0;
658	}
659
660	pub(super) fn take_analyze_after_inspection(&mut self) -> bool {
661		if !std::mem::take(&mut self.analyze_after_inspection) || !self.can_analyze() {
662			return false;
663		}
664		if !self.effective_exclusions().is_empty() {
665			// A refresh never leaves mods out on its own; ask again.
666			self.confirming_exclusions = true;
667			return false;
668		}
669		true
670	}
671
672	pub(super) fn analyzing(&mut self) {
673		self.selectable = false;
674		self.analyzed_with = Some((self.effective_settings(), self.effective_exclusions()));
675		self.phase = Phase::Analyzing {
676			started: Instant::now(),
677			progress: None,
678		};
679	}
680
681	pub(super) fn progress(&mut self, update: MergeProgress) {
682		if let Phase::Analyzing { progress, .. } = &mut self.phase {
683			*progress = Some(update);
684		}
685	}
686
687	pub(super) fn analyzed(&mut self, result: Result<AnalysisView, String>) {
688		match result {
689			Ok(view) => {
690				self.phase = Phase::Reviewed(Box::new(view));
691				self.screen = Screen::Review;
692				self.focus = Focus::Units;
693				self.selected = 0;
694				self.detail_scroll = 0;
695			}
696			Err(error) => self.phase = Phase::Failed(error),
697		}
698	}
699
700	pub fn handle_key(&mut self, key: KeyEvent) -> Option<AppCommand> {
701		if key.kind == KeyEventKind::Release {
702			return None;
703		}
704		if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
705			return Some(AppCommand::Quit);
706		}
707		self.refusal = None;
708		self.notice = None;
709		self.selection = None;
710		if self.help {
711			self.help = false;
712			return (key.code == KeyCode::Char('q')).then_some(AppCommand::Quit);
713		}
714		if self.focus == Focus::Search {
715			self.handle_search_key(key);
716			return None;
717		}
718		if let Some(cursor) = self.options {
719			return self.handle_options_key(key, cursor);
720		}
721		if self.confirming_build {
722			self.confirming_build = false;
723			return match key.code {
724				KeyCode::Char('q') => Some(AppCommand::Quit),
725				KeyCode::Char('y') => Some(AppCommand::BuildBaseData),
726				_ => None,
727			};
728		}
729		if self.confirming_exclusions {
730			return match key.code {
731				KeyCode::Char('q') => Some(AppCommand::Quit),
732				KeyCode::Char('y') => {
733					self.confirming_exclusions = false;
734					Some(AppCommand::Analyze)
735				}
736				KeyCode::Char('n') | KeyCode::Esc => {
737					self.confirming_exclusions = false;
738					None
739				}
740				_ => None,
741			};
742		}
743		match key.code {
744			KeyCode::Char('q') => return Some(AppCommand::Quit),
745			KeyCode::Char('o') => {
746				self.options = Some(0);
747				return None;
748			}
749			KeyCode::Char('?') => {
750				self.help = true;
751				return None;
752			}
753			KeyCode::Char('c') => {
754				let text = self.focused_text();
755				self.notice = Some(format!("Copied {} characters.", text.chars().count()));
756				self.effect = Some(Effect::Copy(text));
757				return None;
758			}
759			KeyCode::Char('C') => {
760				self.notice = Some("Copied the analysis command.".to_string());
761				self.effect = Some(Effect::Copy(self.cli_command()));
762				return None;
763			}
764			KeyCode::Char('m') => {
765				self.mouse_capture = !self.mouse_capture;
766				self.notice = Some(
767					if self.mouse_capture {
768						"Mouse captured: click panels and rows, scroll with the wheel."
769					} else {
770						"Mouse released: select text with the terminal; press m to capture it again."
771					}
772					.to_string(),
773				);
774				self.effect = Some(Effect::MouseCapture(self.mouse_capture));
775				return None;
776			}
777			KeyCode::Char('R') => {
778				self.repair(Vec::new());
779				return None;
780			}
781			KeyCode::Char('B') => {
782				match (self.is_working(), self.data_build_args()) {
783					(true, _) => {
784						self.refusal = Some("Wait for the current work to finish.".to_string())
785					}
786					(false, None) => {
787						self.refusal = Some(
788							"The EU4 installation was not found, so there is nothing to build base data from."
789								.to_string(),
790						);
791					}
792					(false, Some(_)) => self.confirming_build = true,
793				}
794				return None;
795			}
796			KeyCode::Char('w') => {
797				if let Some(id) = self.mods().get(self.mod_scroll).map(|m| m.id.clone()) {
798					self.repair(vec![id]);
799				}
800				return None;
801			}
802			KeyCode::Char('r') if !self.is_working() => return Some(AppCommand::Refresh),
803			KeyCode::Char('a') if self.can_analyze() => {
804				if !self.effective_exclusions().is_empty() {
805					self.confirming_exclusions = true;
806					return None;
807				}
808				return Some(AppCommand::Analyze);
809			}
810			KeyCode::Char('a') => {
811				self.refusal = Some(self.analyze_refusal());
812				return None;
813			}
814			KeyCode::Char('i') | KeyCode::Char('p') if self.analysis().is_some() => {
815				self.screen = match self.screen {
816					Screen::Input => Screen::Review,
817					Screen::Review => Screen::Input,
818				};
819				return None;
820			}
821			_ => {}
822		}
823		match self.screen {
824			Screen::Input => self.handle_input_key(key),
825			Screen::Review => self.handle_review_key(key),
826		}
827		None
828	}
829
830	/// Ask Steam to show the named mods, or every mod that cannot be
831	/// analyzed, through the same targets `foch input repair` lists.
832	fn repair(&mut self, named: Vec<String>) {
833		let Some(input) = &self.input else {
834			return;
835		};
836		let command = if named.is_empty() {
837			"foch input repair --open".to_string()
838		} else {
839			format!("foch input repair --open --mod {}", named.join(" --mod "))
840		};
841		match repair_targets(input, &named) {
842			Ok(targets) if targets.iter().all(|target| target.workshop_id.is_none()) => {
843				self.refusal = Some(if targets.is_empty() {
844					"Every mod can be analyzed; nothing to repair.".to_string()
845				} else {
846					"These mods are not Workshop items; repair them outside Steam.".to_string()
847				});
848			}
849			Ok(targets) => {
850				self.notice = Some(format!(
851					"Opening {} Workshop page(s) in Steam: unsubscribe and subscribe again, wait for the download, then press r.   CLI: {command}",
852					targets
853						.iter()
854						.filter(|target| target.workshop_id.is_some())
855						.count()
856				));
857				self.effect = Some(Effect::OpenWorkshop(targets));
858			}
859			Err(error) => self.refusal = Some(error),
860		}
861	}
862
863	/// The text of the focused panel, for `c`.
864	pub fn focused_text(&self) -> String {
865		if self.analysis().is_some() && self.screen == Screen::Review {
866			let Some(unit) = self.selected_unit() else {
867				return String::new();
868			};
869			return match self.focus {
870				Focus::Detail => super::render::unit_detail(unit)
871					.lines
872					.iter()
873					.map(|line| {
874						line.spans
875							.iter()
876							.map(|span| span.content.as_ref())
877							.collect::<String>()
878					})
879					.collect::<Vec<_>>()
880					.join("\n"),
881				_ => unit.path.as_str().to_owned(),
882			};
883		}
884		let selected = self.mods().get(self.mod_scroll);
885		match self.input_pane {
886			InputPane::Mods => selected
887				.map(|m| format!("#{} {} {}", m.position, m.id, m.name))
888				.unwrap_or_default(),
889			InputPane::Mod => selected
890				.map(|m| {
891					let mut lines = vec![m.name.clone(), format!("#{} {}", m.position, m.id)];
892					if let Some(version) = &m.version {
893						lines.push(format!("version {version}"));
894					}
895					if !m.declared_dependencies.is_empty() {
896						lines.push(format!(
897							"depends on: {}",
898							m.declared_dependencies.join(", ")
899						));
900					}
901					if let Some(error) = &m.source_error {
902						lines.push(error.clone());
903					}
904					lines.join("\n")
905				})
906				.unwrap_or_default(),
907			InputPane::Issues => self
908				.input
909				.iter()
910				.flat_map(|input| &input.issues)
911				.map(|issue| {
912					let mut text = format!("{}: {}", issue.title, issue.detail);
913					if let Some(action) = &issue.action {
914						text.push_str(&format!("\n  -> {action}"));
915					}
916					text
917				})
918				.collect::<Vec<_>>()
919				.join("\n"),
920		}
921	}
922
923	/// Why `a` cannot start an analysis right now.
924	fn analyze_refusal(&self) -> String {
925		let problems = self.problem_mods().len();
926		match self.phase {
927			Phase::Inspecting => {
928				"Still inspecting the input; analysis can start when it finishes.".to_string()
929			}
930			Phase::Building { .. } => {
931				"Base data is being built; analysis can start when it finishes.".to_string()
932			}
933			Phase::Analyzing { .. } => "An analysis is already running.".to_string(),
934			Phase::Reviewed(_) | Phase::Failed(_) => {
935				"This input snapshot was already analyzed. Press r to re-inspect and analyze again."
936					.to_string()
937			}
938			Phase::Inspected if self.selectable && self.needs_base_data() => {
939				"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)."
940					.to_string()
941			}
942			Phase::Inspected if !self.selectable => {
943				"Cannot analyze: the issues listed under Issues block this input. Fix them, then press r."
944					.to_string()
945			}
946			Phase::Inspected if problems > 0 => format!(
947				"{problems} mod{} cannot be analyzed. Press X to exclude {}, or x on a selected mod.",
948				if problems == 1 { "" } else { "s" },
949				if problems == 1 { "it" } else { "them all" }
950			),
951			Phase::Inspected => "Every mod is excluded; press x to include one again.".to_string(),
952		}
953	}
954
955	fn handle_options_key(&mut self, key: KeyEvent, cursor: usize) -> Option<AppCommand> {
956		match key.code {
957			KeyCode::Char('q') => return Some(AppCommand::Quit),
958			KeyCode::Esc | KeyCode::Char('o') => self.options = None,
959			KeyCode::Up | KeyCode::Char('k') => self.options = Some(cursor.saturating_sub(1)),
960			KeyCode::Down | KeyCode::Char('j') => {
961				self.options = Some((cursor + 1).min(OPTION_COUNT - 1));
962			}
963			KeyCode::Left | KeyCode::Char('-') if cursor == WORKERS_OPTION => {
964				self.settings.adjust_workers(false);
965			}
966			KeyCode::Right | KeyCode::Char('+') if cursor == WORKERS_OPTION => {
967				self.settings.adjust_workers(true);
968			}
969			KeyCode::Enter | KeyCode::Char(' ') => self.settings.toggle(cursor),
970			_ => {}
971		}
972		None
973	}
974
975	fn handle_input_key(&mut self, key: KeyEvent) {
976		match key.code {
977			KeyCode::Tab => {
978				self.input_pane = self.input_pane.next();
979				return;
980			}
981			KeyCode::BackTab => {
982				self.input_pane = self.input_pane.previous();
983				return;
984			}
985			_ => {}
986		}
987		match key.code {
988			KeyCode::Char('x') => {
989				if let Some(id) = self.mods().get(self.mod_scroll).map(|m| m.id.clone())
990					&& !self.excluded.remove(&id)
991				{
992					self.excluded.insert(id);
993				}
994				return;
995			}
996			KeyCode::Char('X') => {
997				let problems = self
998					.problem_mods()
999					.into_iter()
1000					.map(|playset_mod| playset_mod.id.clone())
1001					.collect::<Vec<_>>();
1002				self.excluded.extend(problems);
1003				return;
1004			}
1005			_ => {}
1006		}
1007		match self.input_pane {
1008			InputPane::Mods => {
1009				let last = self.mods().len().saturating_sub(1);
1010				self.select_mod(step(self.mod_scroll, key.code, last));
1011			}
1012			InputPane::Mod => {
1013				self.mod_detail_scroll =
1014					step(self.mod_detail_scroll as usize, key.code, u16::MAX as usize) as u16;
1015			}
1016			InputPane::Issues => {
1017				self.issues_scroll =
1018					step(self.issues_scroll as usize, key.code, u16::MAX as usize) as u16;
1019			}
1020		}
1021		if key.code == KeyCode::Esc && self.analysis().is_some() {
1022			self.screen = Screen::Review;
1023		}
1024	}
1025
1026	fn select_mod(&mut self, index: usize) {
1027		if index != self.mod_scroll {
1028			self.mod_scroll = index;
1029			self.mod_detail_scroll = 0;
1030		}
1031	}
1032
1033	fn select_unit(&mut self, index: usize) {
1034		if index != self.selected {
1035			self.selected = index;
1036			self.detail_scroll = 0;
1037		}
1038	}
1039
1040	/// A click focuses the panel under the pointer and, in a list, selects
1041	/// the row; the wheel scrolls the panel under the pointer. Mouse input
1042	/// is ignored while a dialog is open.
1043	pub fn handle_mouse(&mut self, mouse: MouseEvent) {
1044		if self.options.is_some()
1045			|| self.confirming_exclusions
1046			|| self.help
1047			|| self.focus == Focus::Search
1048		{
1049			return;
1050		}
1051		let at = Position::new(mouse.column, mouse.row);
1052		// Dragging selects screen text to copy, as in a plain terminal; a
1053		// click without a drag focuses and selects.
1054		match mouse.kind {
1055			MouseEventKind::Down(MouseButton::Left) => {
1056				let reviewing = self.analysis().is_some() && self.screen == Screen::Review;
1057				self.selection_bounds = self
1058					.hit
1059					.get()
1060					.panels(reviewing)
1061					.into_iter()
1062					.find(|panel| panel.contains(at))
1063					.map(inner)
1064					.unwrap_or(Rect::new(0, at.y, u16::MAX, 1));
1065				self.drag_anchor = Some(clamp(at, self.selection_bounds));
1066				self.selection = None;
1067			}
1068			MouseEventKind::Drag(MouseButton::Left) => {
1069				if let Some(anchor) = self.drag_anchor {
1070					self.selection = Some((anchor, clamp(at, self.selection_bounds)));
1071				}
1072				return;
1073			}
1074			MouseEventKind::Up(MouseButton::Left) => {
1075				self.drag_anchor = None;
1076				if self.selection.is_some_and(|(start, end)| start != end) {
1077					self.copy_selection = true;
1078				} else {
1079					self.selection = None;
1080				}
1081				return;
1082			}
1083			_ => {}
1084		}
1085		let hit = self.hit.get();
1086		let down = match mouse.kind {
1087			MouseEventKind::ScrollDown => Some(true),
1088			MouseEventKind::ScrollUp => Some(false),
1089			MouseEventKind::Down(_) => None,
1090			_ => return,
1091		};
1092		let scroll = |value: usize, last: usize| match down {
1093			Some(true) => value.saturating_add(WHEEL_STEP).min(last),
1094			Some(false) => value.saturating_sub(WHEEL_STEP),
1095			None => value,
1096		};
1097		// The row a click landed on, below a panel's border and any header.
1098		let row = |area: Rect, offset: usize, header: u16| {
1099			let first = area.y + 1 + header;
1100			(at.y >= first && at.y < area.bottom().saturating_sub(1))
1101				.then(|| offset + usize::from(at.y - first))
1102		};
1103		let reviewing = self.analysis().is_some() && self.screen == Screen::Review;
1104		if reviewing {
1105			if hit.units.contains(at) {
1106				self.focus = Focus::Units;
1107				let last = self.visible_units().len().saturating_sub(1);
1108				match (down, row(hit.units, hit.units_offset, 0)) {
1109					(Some(_), _) => self.select_unit(scroll(self.selected, last)),
1110					(None, Some(index)) if index <= last => self.select_unit(index),
1111					_ => {}
1112				}
1113			} else if hit.detail.contains(at) && self.selected_unit().is_some() {
1114				self.focus = Focus::Detail;
1115				self.detail_scroll = scroll(self.detail_scroll as usize, u16::MAX as usize) as u16;
1116			}
1117			return;
1118		}
1119		if hit.mods.contains(at) {
1120			self.input_pane = InputPane::Mods;
1121			let last = self.mods().len().saturating_sub(1);
1122			match (down, row(hit.mods, hit.mods_offset, 1)) {
1123				(Some(_), _) => self.select_mod(scroll(self.mod_scroll, last)),
1124				(None, Some(index)) if index <= last && !self.mods().is_empty() => {
1125					self.select_mod(index);
1126				}
1127				_ => {}
1128			}
1129		} else if hit.selected_mod.contains(at) {
1130			self.input_pane = InputPane::Mod;
1131			self.mod_detail_scroll =
1132				scroll(self.mod_detail_scroll as usize, u16::MAX as usize) as u16;
1133		} else if hit.issues.contains(at) {
1134			self.input_pane = InputPane::Issues;
1135			self.issues_scroll = scroll(self.issues_scroll as usize, u16::MAX as usize) as u16;
1136		}
1137	}
1138
1139	fn handle_review_key(&mut self, key: KeyEvent) {
1140		if let KeyCode::Char(digit @ '0'..='6') = key.code {
1141			let filter = match digit {
1142				'0' => None,
1143				digit => Some(DISPOSITIONS[digit as usize - '1' as usize]),
1144			};
1145			self.set_filter(filter);
1146			return;
1147		}
1148		match (self.focus, key.code) {
1149			(_, KeyCode::Char('/')) => self.focus = Focus::Search,
1150			(Focus::Units, KeyCode::Enter | KeyCode::Right | KeyCode::Tab | KeyCode::BackTab) => {
1151				if self.selected_unit().is_some() {
1152					self.focus = Focus::Detail;
1153				}
1154			}
1155			(Focus::Detail, KeyCode::Esc | KeyCode::Left | KeyCode::Tab | KeyCode::BackTab) => {
1156				self.focus = Focus::Units;
1157			}
1158			(Focus::Detail, code) => {
1159				self.detail_scroll =
1160					step(self.detail_scroll as usize, code, u16::MAX as usize) as u16;
1161			}
1162			(Focus::Units, KeyCode::Esc) if !self.query.is_empty() => self.set_query(String::new()),
1163			(Focus::Units, code) => {
1164				let last = self.visible_units().len().saturating_sub(1);
1165				self.select_unit(step(self.selected, code, last));
1166			}
1167			(Focus::Search, _) => unreachable!("search keys are handled first"),
1168		}
1169	}
1170
1171	fn handle_search_key(&mut self, key: KeyEvent) {
1172		match key.code {
1173			KeyCode::Enter => self.focus = Focus::Units,
1174			KeyCode::Esc => {
1175				self.focus = Focus::Units;
1176				self.set_query(String::new());
1177			}
1178			KeyCode::Backspace => {
1179				let mut query = self.query.clone();
1180				query.pop();
1181				self.set_query(query);
1182			}
1183			KeyCode::Char(value) => {
1184				let mut query = self.query.clone();
1185				query.push(value);
1186				self.set_query(query);
1187			}
1188			_ => {}
1189		}
1190	}
1191
1192	fn set_filter(&mut self, filter: Option<MergeDisposition>) {
1193		self.reselect(|app| app.filter = filter);
1194	}
1195
1196	fn set_query(&mut self, query: String) {
1197		self.reselect(|app| app.query = query);
1198	}
1199
1200	/// Change what is visible while keeping the selected unit selected when
1201	/// it is still visible.
1202	fn reselect(&mut self, change: impl FnOnce(&mut Self)) {
1203		let selected_id = self.selected_unit().map(|unit| unit.id.clone());
1204		change(self);
1205		let visible = self.visible_units();
1206		self.selected = selected_id
1207			.and_then(|id| {
1208				let units = &self.analysis()?.units;
1209				visible.iter().position(|index| units[*index].id == id)
1210			})
1211			.unwrap_or(0);
1212		self.detail_scroll = 0;
1213	}
1214}
1215
1216fn step(current: usize, code: KeyCode, last: usize) -> usize {
1217	match code {
1218		KeyCode::Up | KeyCode::Char('k') => current.saturating_sub(1),
1219		KeyCode::Down | KeyCode::Char('j') => current.saturating_add(1).min(last),
1220		KeyCode::PageUp => current.saturating_sub(PAGE_STEP),
1221		KeyCode::PageDown => current.saturating_add(PAGE_STEP).min(last),
1222		KeyCode::Home | KeyCode::Char('g') => 0,
1223		KeyCode::End | KeyCode::Char('G') => last,
1224		_ => current,
1225	}
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230	use super::*;
1231	use serde_json::json;
1232
1233	/// Mod 2 cannot be analyzed; mod 1 can.
1234	fn input_with_a_broken_mod() -> CurrentEu4Input {
1235		let playset_mod = |position: usize, id: &str, error: Option<&str>| {
1236			json!({
1237				"id": id, "name": format!("Mod {id}"), "position": position, "enabled": true,
1238				"workshopId": id, "workshopManifestId": null, "version": null,
1239				"declaredDependencies": [], "descriptorPath": null, "sourceError": error
1240			})
1241		};
1242		serde_json::from_value(json!({
1243			"readiness": "blocked",
1244			"game": { "name": "Europa Universalis IV", "version": "1.37.5", "installPath": null },
1245			"baseData": { "state": "ready", "version": "1.37.5", "detail": "ready" },
1246			"playset": {
1247				"name": "Current EU4 playset",
1248				"sourcePath": "dlc_load.json",
1249				"mods": [playset_mod(1, "41", None), playset_mod(2, "43", Some("missing"))]
1250			},
1251			"issues": [],
1252			"recovery": null
1253		}))
1254		.expect("input view")
1255	}
1256
1257	fn press(app: &mut App, code: KeyCode) -> Option<AppCommand> {
1258		app.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
1259	}
1260
1261	#[test]
1262	fn a_broken_mod_must_be_excluded_and_the_exclusion_confirmed() {
1263		let mut app = App::default();
1264		app.inspected(input_with_a_broken_mod(), true, true);
1265
1266		assert!(!app.can_analyze());
1267		assert_eq!(press(&mut app, KeyCode::Char('a')), None);
1268		assert!(
1269			app.refusal
1270				.as_deref()
1271				.is_some_and(|refusal| refusal.contains("Press X"))
1272		);
1273
1274		press(&mut app, KeyCode::Char('X'));
1275		assert_eq!(app.excluded_positions(), BTreeSet::from([2]));
1276		assert!(app.can_analyze());
1277		assert!(app.cli_command().ends_with("--exclude 43"));
1278
1279		assert_eq!(press(&mut app, KeyCode::Char('a')), None);
1280		assert!(app.confirming_exclusions);
1281		assert_eq!(press(&mut app, KeyCode::Esc), None);
1282		assert!(!app.confirming_exclusions);
1283		press(&mut app, KeyCode::Char('a'));
1284		assert_eq!(
1285			press(&mut app, KeyCode::Char('y')),
1286			Some(AppCommand::Analyze)
1287		);
1288	}
1289
1290	/// The command the browser shows parses, through the CLI's own parser,
1291	/// back to exactly the arguments the browser analyzes with.
1292	#[test]
1293	fn the_shown_command_is_the_analysis_the_browser_runs() {
1294		use crate::cli::arg::{FochCli, FochCliCommands};
1295		use clap::Parser;
1296
1297		let mut app = App::default();
1298		app.inspected(input_with_a_broken_mod(), true, true);
1299		app.excluded.insert("43".to_string());
1300		app.settings.game_base = false;
1301		app.settings.gui_scroll_merge = true;
1302		app.settings.ignore_replace_path = true;
1303		app.settings.force = true;
1304		app.settings.merge_workers = NonZeroUsize::new(3).expect("non-zero");
1305		for source_path in [None, Some(PathBuf::from("dir with space/dlc_load.json"))] {
1306			app.source_path = source_path;
1307			let expected = app.merge_args(PathBuf::from("<OUT>"));
1308			let argv = std::iter::once("foch".to_string()).chain(expected.command_line());
1309			let parsed = FochCli::try_parse_from(argv).expect("shown command parses");
1310			let Some(FochCliCommands::Merge(parsed)) = parsed.command else {
1311				panic!("shown command is not `foch merge`");
1312			};
1313			assert_eq!(parsed, expected);
1314		}
1315		assert!(
1316			app.cli_command()
1317				.starts_with("foch merge \"dir with space/dlc_load.json\"")
1318		);
1319	}
1320
1321	fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent {
1322		MouseEvent {
1323			kind,
1324			column,
1325			row,
1326			modifiers: KeyModifiers::NONE,
1327		}
1328	}
1329
1330	#[test]
1331	fn tab_moves_the_keyboard_between_playset_panels() {
1332		let mut app = App::default();
1333		app.inspected(input_with_a_broken_mod(), true, true);
1334
1335		assert_eq!(app.input_pane, InputPane::Mods);
1336		press(&mut app, KeyCode::Down);
1337		assert_eq!(app.mod_scroll, 1);
1338		press(&mut app, KeyCode::Tab);
1339		press(&mut app, KeyCode::Tab);
1340		assert_eq!(app.input_pane, InputPane::Issues);
1341		press(&mut app, KeyCode::Down);
1342		assert_eq!(app.issues_scroll, 1);
1343		assert_eq!(app.mod_scroll, 1, "only the focused panel moves");
1344		press(&mut app, KeyCode::BackTab);
1345		assert_eq!(app.input_pane, InputPane::Mod);
1346	}
1347
1348	#[test]
1349	fn a_click_focuses_a_panel_and_selects_its_row() {
1350		use crossterm::event::MouseButton;
1351
1352		let mut app = App::default();
1353		app.inspected(input_with_a_broken_mod(), true, true);
1354		app.hit.set(HitAreas {
1355			mods: Rect::new(0, 10, 40, 10),
1356			selected_mod: Rect::new(40, 10, 40, 5),
1357			issues: Rect::new(40, 15, 40, 5),
1358			..HitAreas::default()
1359		});
1360
1361		// Border at y=10, header at y=11, first mod at y=12.
1362		app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 5, 13));
1363		assert_eq!(app.mod_scroll, 1);
1364		app.handle_mouse(mouse(MouseEventKind::ScrollDown, 50, 17));
1365		assert_eq!(app.input_pane, InputPane::Issues);
1366		assert_eq!(app.issues_scroll, 3);
1367		app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 50, 12));
1368		assert_eq!(app.input_pane, InputPane::Mod);
1369	}
1370
1371	#[test]
1372	fn b_builds_base_data_with_the_command_it_shows_after_consent() {
1373		use crate::cli::arg::{FochCli, FochCliCommands, FochCliDataCommands};
1374		use clap::Parser;
1375
1376		let mut app = App::default();
1377		app.inspected(input_with_a_broken_mod(), true, false);
1378		assert_eq!(press(&mut app, KeyCode::Char('B')), None);
1379		assert!(
1380			app.refusal
1381				.as_deref()
1382				.is_some_and(|refusal| refusal.contains("not found"))
1383		);
1384
1385		let mut input = input_with_a_broken_mod();
1386		input.game.install_path = Some(PathBuf::from("G:/Steam Library/EU4"));
1387		app.inspected(input, true, false);
1388		assert_eq!(press(&mut app, KeyCode::Char('B')), None);
1389		assert!(app.confirming_build);
1390		assert_eq!(
1391			press(&mut app, KeyCode::Char('y')),
1392			Some(AppCommand::BuildBaseData)
1393		);
1394
1395		let expected = app.data_build_args().expect("build arguments");
1396		let argv = std::iter::once("foch".to_string()).chain(expected.command_line());
1397		let parsed = FochCli::try_parse_from(argv).expect("shown command parses");
1398		let Some(FochCliCommands::Data(data)) = parsed.command else {
1399			panic!("shown command is not `foch data`");
1400		};
1401		let FochCliDataCommands::Build(parsed) = data.command else {
1402			panic!("shown command is not `foch data build`");
1403		};
1404		assert_eq!(parsed, expected);
1405	}
1406
1407	#[test]
1408	fn missing_base_data_offers_b_in_the_footer_and_the_build_shows_its_stages() {
1409		use ratatui::Terminal;
1410		use ratatui::backend::TestBackend;
1411
1412		let mut input = input_with_a_broken_mod();
1413		input.game.install_path = Some(PathBuf::from("G:/EU4"));
1414		let mut app = App::default();
1415		app.inspected(input, true, false);
1416		let screen = |app: &App| {
1417			let mut terminal = Terminal::new(TestBackend::new(160, 40)).expect("terminal");
1418			terminal
1419				.draw(|frame| super::super::render::draw(frame, app))
1420				.expect("draw");
1421			let buffer = terminal.backend().buffer().clone();
1422			(0..buffer.area.height)
1423				.map(|y| {
1424					(0..buffer.area.width)
1425						.map(|x| buffer[(x, y)].symbol())
1426						.collect::<String>()
1427				})
1428				.collect::<Vec<_>>()
1429				.join(
1430					"
1431",
1432				)
1433		};
1434		assert!(screen(&app).contains(" B  build base data"));
1435
1436		app.building();
1437		app.build_stage("detect_version".to_string(), false);
1438		app.build_stage("detect_version".to_string(), true);
1439		app.build_stage("collect_inventory".to_string(), false);
1440		let shown = screen(&app);
1441		assert!(shown.contains("stage 2/8"), "{shown}");
1442		assert!(shown.contains("✔ Detect the game version"), "{shown}");
1443	}
1444
1445	#[test]
1446	fn x_toggles_any_selected_mod() {
1447		let mut app = App::default();
1448		app.inspected(input_with_a_broken_mod(), true, true);
1449
1450		press(&mut app, KeyCode::Char('x'));
1451		assert_eq!(app.excluded_positions(), BTreeSet::from([1]));
1452		press(&mut app, KeyCode::Char('x'));
1453		assert!(app.excluded_positions().is_empty());
1454	}
1455
1456	#[test]
1457	fn a_blocked_analyze_key_says_why_until_the_next_key() {
1458		let mut app = App::default();
1459		app.inspected(input_with_a_broken_mod(), false, true);
1460
1461		assert_eq!(press(&mut app, KeyCode::Char('a')), None);
1462		assert!(
1463			app.refusal
1464				.as_deref()
1465				.is_some_and(|refusal| refusal.contains("press r"))
1466		);
1467		press(&mut app, KeyCode::Down);
1468		assert!(app.refusal.is_none());
1469	}
1470
1471	#[test]
1472	fn a_refresh_asks_again_before_leaving_mods_out() {
1473		let mut app = App::default();
1474		app.excluded.insert("43".to_string());
1475		app.inspecting(true);
1476		app.inspected(input_with_a_broken_mod(), true, true);
1477
1478		assert!(!app.take_analyze_after_inspection());
1479		assert!(app.confirming_exclusions);
1480	}
1481}