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
22pub 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#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub struct AnalysisSettings {
77 pub game_base: bool,
80 pub gui_scroll_merge: bool,
82 pub ignore_replace_path: bool,
84 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
101pub 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#[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#[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 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
200fn 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
210fn 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 {
232 started: Instant,
233 stage: Option<String>,
235 finished: usize,
237 },
238}
239
240#[derive(Clone, Copy, Debug, Eq, PartialEq)]
242pub enum AppCommand {
243 Quit,
244 Analyze,
245 Refresh,
246 BuildBaseData,
247}
248
249#[derive(Clone, Debug, Eq, PartialEq)]
252pub enum Effect {
253 Copy(String),
255 OpenWorkshop(Vec<RepairTarget>),
258 MouseCapture(bool),
261}
262
263pub 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
276pub 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#[derive(Debug)]
328pub struct App {
329 pub phase: Phase,
330 pub screen: Screen,
331 pub focus: Focus,
332 pub input: Option<CurrentEu4Input>,
333 pub selectable: bool,
336 pub excluded: BTreeSet<String>,
339 pub source_path: Option<PathBuf>,
342 pub mod_scroll: usize,
343 pub filter: Option<MergeDisposition>,
344 pub query: String,
345 pub selected: usize,
347 pub detail_scroll: u16,
348 pub input_pane: InputPane,
350 pub mod_detail_scroll: u16,
351 pub issues_scroll: u16,
352 pub hit: Cell<HitAreas>,
354 pub settings: AnalysisSettings,
355 pub analyzed_with: Option<(AnalysisSettings, BTreeSet<String>)>,
357 pub game_base_available: bool,
359 pub options: Option<usize>,
361 pub confirming_exclusions: bool,
363 pub confirming_build: bool,
365 pub refusal: Option<String>,
367 pub notice: Option<String>,
369 pub effect: Option<Effect>,
371 pub mouse_capture: bool,
372 pub help: bool,
373 pub drag_anchor: Option<Position>,
375 pub selection: Option<(Position, Position)>,
378 pub selection_bounds: Rect,
381 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 pub fn effective_settings(&self) -> AnalysisSettings {
435 self.settings
436 }
437
438 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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}