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 }
584 }
585
586 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 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 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 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 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 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 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 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 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 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 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 #[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 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}