1use foch::input::{BaseDataState, CurrentEu4Input, DetectedPlaysetMod};
2use foch::merge::{
3 MergeAnalysisStage, MergeAnalysisStatus, MergeDisposition, MergeProgress, MergeUnitKind,
4 MergeUnitOutcome,
5};
6use ratatui::Frame;
7use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect};
8use ratatui::style::{Color, Style};
9use ratatui::text::{Line, Span, Text};
10use ratatui::widgets::{
11 Block, BorderType, Borders, Cell, Clear, Gauge, List, ListItem, ListState, Paragraph, Row,
12 Table, TableState, Wrap,
13};
14
15use super::app::{
16 App, BUILD_STAGES, DISPOSITIONS, Focus, HitAreas, InputPane, KEY_HELP, OPTION_COUNT, Phase,
17 Screen, disposition_label,
18};
19
20const ACCENT: Color = Color::Cyan;
21const DIM: Color = Color::DarkGray;
22const OK: Color = Color::Green;
23const WARN: Color = Color::Yellow;
24const BAD: Color = Color::Red;
25
26const STAGES: [(MergeAnalysisStage, &str); 5] = [
27 (MergeAnalysisStage::Inventory, "Inventory inputs"),
28 (MergeAnalysisStage::ResolveInput, "Resolve the playset"),
29 (MergeAnalysisStage::SemanticMerge, "Merge every unit"),
30 (MergeAnalysisStage::ValidateOutput, "Validate the result"),
31 (MergeAnalysisStage::FreezeArtifacts, "Freeze the result"),
32];
33
34pub fn draw(frame: &mut Frame<'_>, app: &App) {
35 let [top, header, body, footer] = Layout::vertical([
36 Constraint::Length(1),
37 Constraint::Length(5),
38 Constraint::Min(6),
39 Constraint::Length(1),
40 ])
41 .areas(frame.area());
42 draw_top_bar(frame, app, top);
43 draw_header(frame, app, header);
44 match (&app.phase, app.screen) {
45 (
46 Phase::Building {
47 started,
48 stage,
49 finished,
50 },
51 _,
52 ) => draw_building(
53 frame,
54 app,
55 body,
56 started.elapsed(),
57 stage.as_deref(),
58 *finished,
59 ),
60 (Phase::Analyzing { started, progress }, _) => {
61 let elapsed = progress
62 .map(|progress| progress.elapsed)
63 .unwrap_or_else(|| started.elapsed());
64 draw_progress(frame, body, *progress, elapsed);
65 }
66 (Phase::Reviewed(_), Screen::Review) => draw_review(frame, app, body),
67 _ => draw_input(frame, app, body),
68 }
69 frame.render_widget(Paragraph::new(footer_keys(app)), footer);
70 if let Some(cursor) = app.options {
71 draw_options(frame, app, cursor);
72 }
73 if app.confirming_exclusions {
74 draw_exclusion_prompt(frame, app);
75 }
76 if app.confirming_build {
77 draw_build_prompt(frame, app);
78 }
79 if app.help {
80 draw_help(frame);
81 }
82 if let Some(selection) = app.selection {
83 let buffer = frame.buffer_mut();
84 let bounds = app.selection_bounds.intersection(buffer.area);
85 for position in selected_cells(selection, bounds) {
86 buffer[position]
87 .modifier
88 .toggle(ratatui::style::Modifier::REVERSED);
89 }
90 }
91}
92
93fn selected_cells((start, end): (Position, Position), area: Rect) -> Vec<Position> {
97 let (start, end) = if (start.y, start.x) <= (end.y, end.x) {
98 (start, end)
99 } else {
100 (end, start)
101 };
102 let mut cells = Vec::new();
103 for y in start.y..=end.y.min(area.bottom().saturating_sub(1)) {
104 let first = if y == start.y { start.x } else { area.x };
105 let last = if y == end.y {
106 end.x
107 } else {
108 area.right().saturating_sub(1)
109 };
110 for x in first..=last.min(area.right().saturating_sub(1)) {
111 cells.push(Position::new(x, y));
112 }
113 }
114 cells
115}
116
117pub fn selected_text(
120 selection: (Position, Position),
121 bounds: Rect,
122 buffer: &ratatui::buffer::Buffer,
123) -> String {
124 const BORDERS: &[char] = &['│', '┃', '─', '━', '╭', '╮', '╰', '╯', '┏', '┓', '┗', '┛'];
125 let mut lines: Vec<String> = Vec::new();
126 let mut row = None;
127 let mut skip = 0;
128 for position in selected_cells(selection, bounds.intersection(buffer.area)) {
129 if row != Some(position.y) {
130 row = Some(position.y);
131 skip = 0;
132 lines.push(String::new());
133 }
134 if skip > 0 {
135 skip -= 1;
136 continue;
137 }
138 let symbol = buffer[position].symbol();
139 skip = Span::raw(symbol).width().saturating_sub(1);
140 if let Some(line) = lines.last_mut() {
141 line.push_str(symbol);
142 }
143 }
144 lines
145 .iter()
146 .map(|line| line.trim_matches(|c: char| BORDERS.contains(&c) || c == ' '))
147 .collect::<Vec<_>>()
148 .join("\n")
149 .trim_matches('\n')
150 .to_string()
151}
152
153fn draw_build_prompt(frame: &mut Frame<'_>, app: &App) {
154 let Some(args) = app.data_build_args() else {
155 return;
156 };
157 let command = std::iter::once("foch".to_string())
158 .chain(args.command_line())
159 .collect::<Vec<_>>()
160 .join(" ");
161 let lines = vec![
162 Line::from(Span::styled(
163 "Build EU4 base data from your game installation?",
164 Style::new().bold(),
165 )),
166 Line::from(Span::styled(
167 format!("Reads {}", display(&args.from_game_path)),
168 Style::new().fg(DIM),
169 )),
170 Line::from(Span::styled(
171 "Parses the whole game (several minutes) and installs the result into Foch's data directory. Game files are not changed.",
172 Style::new().fg(DIM),
173 )),
174 Line::from(""),
175 Line::from(vec![
176 Span::styled("CLI ", Style::new().fg(DIM)),
177 Span::styled(command, Style::new().fg(Color::Gray)),
178 ]),
179 Line::from(""),
180 Line::from(vec![
181 key("y"),
182 Span::raw(" build "),
183 key("Esc"),
184 Span::raw(" cancel"),
185 ]),
186 ];
187 let height = lines.len() as u16 + 4;
188 let popup = centered(frame.area(), 100, height);
189 frame.render_widget(Clear, popup);
190 frame.render_widget(
191 Paragraph::new(lines)
192 .wrap(Wrap { trim: false })
193 .block(panel(" Build base data ", WARN)),
194 popup,
195 );
196}
197
198fn draw_building(
199 frame: &mut Frame<'_>,
200 app: &App,
201 area: Rect,
202 elapsed: std::time::Duration,
203 stage: Option<&str>,
204 finished: usize,
205) {
206 let popup = centered(area, 80, BUILD_STAGES.len() as u16 + 8);
207 let block = panel(" Building EU4 base data ", ACCENT);
208 let inner = block.inner(popup);
209 frame.render_widget(Clear, popup);
210 frame.render_widget(block, popup);
211 let [from_area, gauge_area, _, stages_area, note_area] = Layout::vertical([
212 Constraint::Length(1),
213 Constraint::Length(1),
214 Constraint::Length(1),
215 Constraint::Length(BUILD_STAGES.len() as u16),
216 Constraint::Min(1),
217 ])
218 .areas(inner);
219 let from = app
220 .data_build_args()
221 .map(|args| display(&args.from_game_path))
222 .unwrap_or_default();
223 frame.render_widget(
224 Paragraph::new(Span::styled(format!(" from {from}"), Style::new().fg(DIM))),
225 from_area,
226 );
227 let total = BUILD_STAGES.len();
228 frame.render_widget(
229 Gauge::default()
230 .gauge_style(Style::new().fg(ACCENT).bg(Color::Rgb(30, 36, 44)))
231 .ratio((finished as f64 / total as f64).clamp(0.0, 1.0))
232 .label(format!(
233 "stage {}/{total} {}s",
234 (finished + 1).min(total),
235 elapsed.as_secs()
236 )),
237 gauge_area,
238 );
239 let spinner = ["◐", "◓", "◑", "◒"][(elapsed.as_millis() / 250 % 4) as usize];
240 let current = stage.and_then(|name| BUILD_STAGES.iter().position(|(key, _)| *key == name));
241 let lines = BUILD_STAGES
242 .iter()
243 .enumerate()
244 .map(|(index, (_, label))| {
245 let (mark, style) = if index < finished {
246 ("✔", Style::new().fg(OK))
247 } else if Some(index) == current || (current.is_none() && index == finished) {
248 (spinner, Style::new().fg(ACCENT).bold())
249 } else {
250 ("○", Style::new().fg(DIM))
251 };
252 Line::from(Span::styled(format!(" {mark} {label}"), style))
253 })
254 .collect::<Vec<_>>();
255 frame.render_widget(Paragraph::new(lines), stages_area);
256 frame.render_widget(
257 Paragraph::new(Span::styled(
258 " Parsing takes most of the time. The input is inspected again when the build finishes.",
259 Style::new().fg(DIM),
260 ))
261 .wrap(Wrap { trim: false }),
262 note_area,
263 );
264}
265
266fn draw_help(frame: &mut Frame<'_>) {
267 let mut lines = vec![
268 Line::from(Span::styled(
269 "Every browser action is also a foch command:",
270 Style::new().fg(DIM),
271 )),
272 Line::from(""),
273 ];
274 for (name, action, command) in KEY_HELP {
275 lines.push(Line::from(vec![
276 Span::raw(format!("{:>6} ", "")),
277 key(name),
278 Span::raw(format!(" {action:<46}")),
279 Span::styled(command.to_string(), Style::new().fg(Color::Gray)),
280 ]));
281 }
282 lines.push(Line::from(""));
283 lines.push(Line::from(Span::styled(
284 "Press any key to close.",
285 Style::new().fg(DIM),
286 )));
287 let height = lines.len() as u16 + 2;
288 let popup = centered(frame.area(), 110, height);
289 frame.render_widget(Clear, popup);
290 frame.render_widget(Paragraph::new(lines).block(panel(" Keys ", ACCENT)), popup);
291}
292
293fn draw_top_bar(frame: &mut Frame<'_>, app: &App, area: Rect) {
294 let tab = |name: &'static str, active: bool| {
295 if active {
296 Span::styled(
297 format!(" {name} "),
298 Style::new().fg(Color::Black).bg(ACCENT).bold(),
299 )
300 } else {
301 Span::styled(format!(" {name} "), Style::new().fg(DIM))
302 }
303 };
304 let reviewing = matches!(app.phase, Phase::Reviewed(_)) && app.screen == Screen::Review;
305 let left = Line::from(vec![
306 Span::styled(" foch ", Style::new().fg(Color::Black).bg(ACCENT).bold()),
307 Span::styled(" EU4 merge analysis ", Style::new().bold()),
308 tab("Playset", !reviewing),
309 Span::raw(" "),
310 tab("Review", reviewing),
311 ]);
312 frame.render_widget(Paragraph::new(left), area);
313 frame.render_widget(
314 Paragraph::new(Span::styled(
315 "analysis writes nothing ",
316 Style::new().fg(DIM),
317 ))
318 .alignment(Alignment::Right),
319 area,
320 );
321}
322
323fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) {
324 let lines = match &app.input {
325 None => vec![Line::from(Span::styled(
326 "◌ Inspecting the installed EU4 game and launcher playset…",
327 Style::new().fg(ACCENT),
328 ))],
329 Some(input) => header_lines(input),
330 };
331 frame.render_widget(Paragraph::new(lines).block(panel(" Input ", DIM)), area);
332 record(app, |hit| hit.header = area);
333}
334
335fn header_lines(input: &CurrentEu4Input) -> Vec<Line<'static>> {
336 let game = Line::from(vec![
337 field("Game"),
338 Span::raw(format!(
339 "{} {}",
340 input.game.name,
341 input.game.version.as_deref().unwrap_or("(version unknown)")
342 )),
343 Span::styled(
344 input.game.install_path.as_ref().map_or_else(
345 || " not found".to_string(),
346 |path| format!(" {}", display(path)),
347 ),
348 Style::new().fg(DIM),
349 ),
350 ]);
351 let (base_state, base_color) = match input.base_data.state {
352 BaseDataState::Ready => ("ready", OK),
353 BaseDataState::Missing => ("missing", BAD),
354 BaseDataState::Stale => ("stale", WARN),
355 };
356 let base = Line::from(vec![
357 field("Base data"),
358 badge(base_state, base_color),
359 Span::raw(format!(
360 " {}",
361 input.base_data.version.as_deref().unwrap_or_default()
362 )),
363 Span::styled(
364 format!(" {}", input.base_data.detail),
365 Style::new().fg(DIM),
366 ),
367 ]);
368 let playset = match &input.playset {
369 None => Line::from(vec![
370 field("Playset"),
371 Span::styled("no launcher playset found", Style::new().fg(BAD)),
372 ]),
373 Some(playset) => Line::from(vec![
374 field("Playset"),
375 Span::raw(format!("{} · {} mods", playset.name, playset.mods.len())),
376 Span::styled(
377 format!(" {}", display(&playset.source_path)),
378 Style::new().fg(DIM),
379 ),
380 ]),
381 };
382 vec![game, base, playset]
383}
384
385fn next_step(app: &App) -> (Line<'static>, Color) {
387 let mod_count = app
388 .input
389 .as_ref()
390 .and_then(|input| input.playset.as_ref())
391 .map_or(0, |playset| playset.mods.len());
392 let issue_count = app.input.as_ref().map_or(0, |input| input.issues.len());
393 let mut step = match &app.phase {
394 Phase::Inspecting => (
395 Line::from("◌ Inspecting the installed EU4 game and launcher playset…"),
396 ACCENT,
397 ),
398 Phase::Failed(_) => (
399 Line::from(vec![
400 Span::raw("✖ The analysis failed; the reason is under Issues. Press "),
401 key("r"),
402 Span::raw(" to inspect and try again."),
403 ]),
404 BAD,
405 ),
406 Phase::Reviewed(_) => (
407 Line::from(vec![
408 Span::raw("✔ Analysis complete. Press "),
409 key("i"),
410 Span::raw(" to return to the review."),
411 ]),
412 OK,
413 ),
414 _ if app.selectable && app.needs_base_data() => (
415 Line::from(vec![
416 Span::raw("⚠ The EU4 base data is not ready. Press "),
417 key("B"),
418 Span::raw(" to build it here, or "),
419 key("o"),
420 Span::raw(" to turn off the EU4 base as ancestor (--no-game-base)."),
421 ]),
422 WARN,
423 ),
424 _ if app.selectable && !app.problem_mods().is_empty() => {
425 let problems = app.problem_mods().len();
426 (
427 Line::from(vec![
428 Span::raw(format!(
429 "⚠ {problems} of {mod_count} mods cannot be analyzed. Press "
430 )),
431 key("X"),
432 Span::raw(" to exclude them (or "),
433 key("x"),
434 Span::raw(" on one), then "),
435 key("a"),
436 Span::raw("."),
437 ]),
438 WARN,
439 )
440 }
441 _ if app.can_analyze() && !app.effective_exclusions().is_empty() => (
442 Line::from(vec![
443 Span::raw(format!(
444 "✔ Ready without {} excluded mods. Press ",
445 app.effective_exclusions().len()
446 )),
447 key("a"),
448 Span::raw(format!(" to analyze the other {}.", app.included_count())),
449 ]),
450 OK,
451 ),
452 _ if app.can_analyze() => (
453 Line::from(vec![
454 Span::raw("✔ Ready. Press "),
455 key("a"),
456 Span::raw(format!(" to analyze all {mod_count} mods.")),
457 ]),
458 OK,
459 ),
460 _ if app.selectable => (
461 Line::from(vec![
462 Span::raw("⚠ Every mod is excluded. Press "),
463 key("x"),
464 Span::raw(" on a mod to include it again."),
465 ]),
466 WARN,
467 ),
468 _ => (
469 Line::from(vec![
470 Span::raw(format!(
471 "✖ Analysis is blocked by {issue_count} issue{} listed below. Fix {}, then press ",
472 if issue_count == 1 { "" } else { "s" },
473 if issue_count == 1 { "it" } else { "them" }
474 )),
475 key("r"),
476 Span::raw("."),
477 ]),
478 BAD,
479 ),
480 };
481 if app.settings_changed() {
482 step.0.spans.push(Span::styled(
483 " Options changed: press r to re-analyze.",
484 Style::new().fg(WARN),
485 ));
486 }
487 step
488}
489
490fn draw_input(frame: &mut Frame<'_>, app: &App, area: Rect) {
491 let [banner_area, main] =
492 Layout::vertical([Constraint::Length(5), Constraint::Min(3)]).areas(area);
493 let (step, color) = match (&app.refusal, &app.notice) {
494 (Some(refusal), _) => (Line::from(format!("✖ {refusal}")), BAD),
495 (None, Some(notice)) => (Line::from(format!("✔ {notice}")), ACCENT),
496 (None, None) => next_step(app),
497 };
498 let command = Line::from(vec![
499 Span::styled("CLI ", Style::new().fg(DIM)),
500 Span::styled(app.cli_command(), Style::new().fg(Color::Gray)),
501 ]);
502 frame.render_widget(
503 Paragraph::new(vec![step.style(Style::new().fg(color).bold()), command])
504 .wrap(Wrap { trim: false })
505 .block(panel(" Next step ", color)),
506 banner_area,
507 );
508 record(app, |hit| hit.banner = banner_area);
509
510 let [mods_area, side] =
511 Layout::horizontal([Constraint::Percentage(62), Constraint::Percentage(38)]).areas(main);
512 let [selected_area, issues_area] =
513 Layout::vertical([Constraint::Length(9), Constraint::Min(3)]).areas(side);
514
515 let mods = app
516 .input
517 .as_ref()
518 .and_then(|input| input.playset.as_ref())
519 .map_or(&[][..], |playset| &playset.mods);
520 draw_mod_table(frame, app, mods, mods_area);
521 draw_selected_mod(frame, app, mods.get(app.mod_scroll), selected_area);
522 draw_issues(frame, app, issues_area);
523}
524
525fn draw_mod_table(frame: &mut Frame<'_>, app: &App, mods: &[DetectedPlaysetMod], area: Rect) {
526 let with_version = area.width >= 90;
528 let rows = mods
529 .iter()
530 .map(|playset_mod| {
531 let (status, color) = mod_status(app, playset_mod);
532 let name_style = if app.is_excluded(playset_mod) {
533 Style::new().fg(DIM).crossed_out()
534 } else {
535 Style::new()
536 };
537 let mut cells = vec![
538 Cell::from(Span::styled(
539 format!("{:>3}", playset_mod.position),
540 Style::new().fg(DIM),
541 )),
542 Cell::from(Span::styled(playset_mod.name.clone(), name_style)),
543 Cell::from(Span::styled(playset_mod.id.clone(), Style::new().fg(DIM))),
544 ];
545 if with_version {
546 cells.push(Cell::from(Span::styled(
547 playset_mod.version.clone().unwrap_or_default(),
548 Style::new().fg(DIM),
549 )));
550 }
551 cells.push(Cell::from(Span::styled(
552 format!("● {status}"),
553 Style::new().fg(color),
554 )));
555 Row::new(cells)
556 })
557 .collect::<Vec<_>>();
558 let (header, widths) = if with_version {
559 (
560 Row::new([" #", "Mod", "Workshop id", "Version", "Status"]),
561 vec![
562 Constraint::Length(3),
563 Constraint::Min(12),
564 Constraint::Length(10),
565 Constraint::Length(9),
566 Constraint::Length(16),
567 ],
568 )
569 } else {
570 (
571 Row::new([" #", "Mod", "Workshop id", "Status"]),
572 vec![
573 Constraint::Length(3),
574 Constraint::Min(12),
575 Constraint::Length(10),
576 Constraint::Length(16),
577 ],
578 )
579 };
580 let table = Table::new(rows, widths)
581 .header(header.style(Style::new().fg(ACCENT).bold()))
582 .column_spacing(1)
583 .row_highlight_style(Style::new().bg(Color::Rgb(40, 52, 64)).bold())
584 .highlight_symbol("▶ ")
585 .block(focus_panel(
586 format!(" Mods · {} in load order (later wins) ", mods.len()),
587 DIM,
588 app.input_pane == InputPane::Mods,
589 ));
590 let mut state =
591 TableState::default().with_selected((!mods.is_empty()).then_some(app.mod_scroll));
592 frame.render_stateful_widget(table, area, &mut state);
593 record(app, |hit| {
594 hit.mods = area;
595 hit.mods_offset = state.offset();
596 });
597}
598
599fn mod_status(app: &App, playset_mod: &DetectedPlaysetMod) -> (&'static str, Color) {
600 if app.is_excluded(playset_mod) {
601 ("excluded", DIM)
602 } else if !playset_mod.enabled {
603 ("disabled", DIM)
604 } else if playset_mod.source_error.is_some() {
605 ("cannot analyze", BAD)
606 } else {
607 ("ok", OK)
608 }
609}
610
611fn draw_selected_mod(
612 frame: &mut Frame<'_>,
613 app: &App,
614 playset_mod: Option<&DetectedPlaysetMod>,
615 area: Rect,
616) {
617 let Some(playset_mod) = playset_mod else {
618 frame.render_widget(
619 Paragraph::new(Span::styled("No mod selected.", Style::new().fg(DIM)))
620 .block(panel(" Selected mod ", DIM)),
621 area,
622 );
623 return;
624 };
625 let (status, color) = mod_status(app, playset_mod);
626 let mut lines = vec![
627 Line::from(Span::styled(playset_mod.name.clone(), Style::new().bold())),
628 Line::from(vec![
629 badge(status, color),
630 Span::styled(
631 format!(
632 " #{} · {}{}",
633 playset_mod.position,
634 playset_mod.id,
635 playset_mod
636 .version
637 .as_deref()
638 .map(|version| format!(" · v{version}"))
639 .unwrap_or_default()
640 ),
641 Style::new().fg(DIM),
642 ),
643 ]),
644 ];
645 if playset_mod.declared_dependencies.is_empty() {
646 lines.push(Line::from(Span::styled(
647 "No declared dependencies",
648 Style::new().fg(DIM),
649 )));
650 } else {
651 lines.push(Line::from(vec![
652 Span::styled("depends on: ", Style::new().fg(ACCENT)),
653 Span::raw(playset_mod.declared_dependencies.join(", ")),
654 ]));
655 }
656 if let Some(error) = &playset_mod.source_error {
657 lines.push(Line::from(Span::styled(
658 clean(error),
659 Style::new().fg(color),
660 )));
661 }
662 lines.push(Line::from(vec![
663 key("x"),
664 Span::styled(
665 if app.is_excluded(playset_mod) {
666 " include in the analysis"
667 } else {
668 " exclude from the analysis"
669 },
670 Style::new().fg(DIM),
671 ),
672 ]));
673 frame.render_widget(
674 Paragraph::new(lines)
675 .wrap(Wrap { trim: false })
676 .scroll((app.mod_detail_scroll, 0))
677 .block(focus_panel(
678 " Selected mod ",
679 DIM,
680 app.input_pane == InputPane::Mod,
681 )),
682 area,
683 );
684 record(app, |hit| hit.selected_mod = area);
685}
686
687fn draw_issues(frame: &mut Frame<'_>, app: &App, area: Rect) {
688 let mut lines = Vec::new();
689 if let Phase::Failed(error) = &app.phase {
690 lines.push(Line::from(Span::styled(
691 "✖ Analysis failed",
692 Style::new().fg(BAD).bold(),
693 )));
694 lines.push(Line::from(Span::styled(clean(error), Style::new().fg(BAD))));
695 lines.push(Line::from(""));
696 }
697 let issues = app
698 .input
699 .as_ref()
700 .map_or(&[][..], |input| &input.issues[..]);
701 for issue in issues {
702 lines.push(Line::from(Span::styled(
703 format!("⚠ {}", issue.title),
704 Style::new().fg(WARN).bold(),
705 )));
706 lines.push(Line::from(Span::styled(
707 clean(&issue.detail),
708 Style::new().fg(DIM),
709 )));
710 if let Some(action) = &issue.action {
711 lines.push(Line::from(Span::styled(
712 format!("→ {action}"),
713 Style::new().fg(ACCENT),
714 )));
715 }
716 lines.push(Line::from(""));
717 }
718 let color = if lines.is_empty() {
719 lines.push(Line::from(Span::styled("✔ No issues", Style::new().fg(OK))));
720 DIM
721 } else {
722 WARN
723 };
724 frame.render_widget(
725 Paragraph::new(lines)
726 .wrap(Wrap { trim: false })
727 .scroll((app.issues_scroll, 0))
728 .block(focus_panel(
729 format!(" Issues · {} ", issues.len()),
730 color,
731 app.input_pane == InputPane::Issues,
732 )),
733 area,
734 );
735 record(app, |hit| hit.issues = area);
736}
737
738fn draw_progress(
739 frame: &mut Frame<'_>,
740 area: Rect,
741 progress: Option<MergeProgress>,
742 elapsed: std::time::Duration,
743) {
744 let popup = centered(area, 72, 13);
745 let block = panel(" Analyzing every contributor ", ACCENT);
746 let inner = block.inner(popup);
747 frame.render_widget(block, popup);
748 let [gauge_area, _, stages_area, note_area] = Layout::vertical([
749 Constraint::Length(1),
750 Constraint::Length(1),
751 Constraint::Length(STAGES.len() as u16),
752 Constraint::Min(1),
753 ])
754 .areas(inner);
755
756 let (ratio, units) = match progress.and_then(|p| p.completed_units.zip(p.total_units)) {
757 Some((done, total)) if total > 0 => (
758 (done as f64 / total as f64).clamp(0.0, 1.0),
759 format!("{done}/{total} units"),
760 ),
761 _ => (0.0, String::new()),
762 };
763 frame.render_widget(
764 Gauge::default()
765 .gauge_style(Style::new().fg(ACCENT).bg(Color::Rgb(30, 36, 44)))
766 .ratio(ratio)
767 .label(format!("{units} {}s", elapsed.as_secs())),
768 gauge_area,
769 );
770
771 let current = progress.map(|progress| {
772 let index = STAGES
773 .iter()
774 .position(|(stage, _)| *stage == progress.stage)
775 .unwrap_or(0);
776 (index, progress.completed)
777 });
778 let stages = STAGES
779 .iter()
780 .enumerate()
781 .map(|(index, (_, name))| {
782 let (mark, style) = match current {
783 Some((at, completed)) if index < at || (index == at && completed) => {
784 ("✔", Style::new().fg(OK))
785 }
786 Some((at, _)) if index == at => ("◐", Style::new().fg(ACCENT).bold()),
787 _ => ("○", Style::new().fg(DIM)),
788 };
789 Line::from(Span::styled(format!(" {mark} {name}"), style))
790 })
791 .collect::<Vec<_>>();
792 frame.render_widget(Paragraph::new(stages), stages_area);
793 frame.render_widget(
794 Paragraph::new(Line::from(vec![
795 Span::styled(
796 " Runs to completion over the frozen input. Press ",
797 Style::new().fg(DIM),
798 ),
799 key("q"),
800 Span::styled(" to cancel and quit.", Style::new().fg(DIM)),
801 ]))
802 .wrap(Wrap { trim: false }),
803 note_area,
804 );
805}
806
807fn draw_review(frame: &mut Frame<'_>, app: &App, area: Rect) {
808 let Some(view) = app.analysis() else {
809 return;
810 };
811 let [summary_area, main] =
812 Layout::vertical([Constraint::Length(4), Constraint::Min(3)]).areas(area);
813
814 let mut chips = vec![chip(
815 format!("0 all {}", view.summary.total),
816 Color::White,
817 app.filter.is_none(),
818 )];
819 for (index, disposition) in DISPOSITIONS.iter().enumerate() {
820 chips.push(Span::raw(" "));
821 chips.push(chip(
822 format!(
823 "{} {} {}",
824 index + 1,
825 disposition_label(*disposition),
826 view.count(*disposition)
827 ),
828 disposition_color(*disposition),
829 app.filter == Some(*disposition),
830 ));
831 }
832 let (status, color) = match view.status {
833 MergeAnalysisStatus::ReadyToCommit => ("ready to commit", OK),
834 MergeAnalysisStatus::CommittableWithDeferrals => ("committable with deferrals", WARN),
835 MergeAnalysisStatus::Blocked => ("blocked", BAD),
836 };
837 let mut search = match (app.focus, app.query.is_empty()) {
838 (Focus::Search, _) => vec![
839 Span::styled("search ", Style::new().fg(ACCENT)),
840 Span::raw(format!("{}▏", app.query)),
841 ],
842 (_, false) => vec![
843 Span::styled("search ", Style::new().fg(ACCENT)),
844 Span::raw(app.query.clone()),
845 ],
846 (_, true) => vec![Span::styled(
847 "press / to search paths",
848 Style::new().fg(DIM),
849 )],
850 };
851 if let Some(refusal) = &app.refusal {
852 search = vec![Span::styled(
853 format!("✖ {refusal}"),
854 Style::new().fg(BAD).bold(),
855 )];
856 } else if let Some(notice) = &app.notice {
857 search = vec![Span::styled(
858 format!("✔ {notice}"),
859 Style::new().fg(ACCENT).bold(),
860 )];
861 }
862 if app.settings_changed() {
863 search.push(Span::styled(
864 " Options changed: press r to re-analyze.",
865 Style::new().fg(WARN),
866 ));
867 }
868 frame.render_widget(
869 Paragraph::new(vec![Line::from(chips), Line::from(search)])
870 .block(panel(format!(" Analysis · {status} "), color)),
871 summary_area,
872 );
873 record(app, |hit| hit.summary = summary_area);
874
875 let [list_area, detail_area] =
876 Layout::horizontal([Constraint::Percentage(42), Constraint::Percentage(58)]).areas(main);
877 let visible = app.visible_units();
878 let items = visible
879 .iter()
880 .map(|index| {
881 let unit = &view.units[*index];
882 ListItem::new(Line::from(vec![
883 Span::styled(
884 format!("{:<6}", short_disposition(unit.disposition)),
885 Style::new().fg(disposition_color(unit.disposition)).bold(),
886 ),
887 Span::raw(" "),
888 Span::raw(unit.path.as_str().to_owned()),
889 ]))
890 })
891 .collect::<Vec<_>>();
892 let mut state =
893 ListState::default().with_selected((!visible.is_empty()).then_some(app.selected));
894 frame.render_stateful_widget(
895 List::new(items)
896 .block(focus_panel(
897 format!(" Units · {}/{} ", visible.len(), view.units.len()),
898 DIM,
899 app.focus == Focus::Units,
900 ))
901 .highlight_style(Style::new().bg(Color::Rgb(40, 52, 64)).bold())
902 .highlight_symbol("▶ "),
903 list_area,
904 &mut state,
905 );
906 record(app, |hit| {
907 hit.units = list_area;
908 hit.units_offset = state.offset();
909 hit.detail = detail_area;
910 });
911
912 let detail = app.selected_unit().map_or_else(
913 || {
914 Text::from(Span::styled(
915 "No unit matches this filter.",
916 Style::new().fg(DIM),
917 ))
918 },
919 unit_detail,
920 );
921 frame.render_widget(
922 Paragraph::new(detail)
923 .wrap(Wrap { trim: false })
924 .scroll((app.detail_scroll, 0))
925 .block(focus_panel(" Detail ", DIM, app.focus == Focus::Detail)),
926 detail_area,
927 );
928}
929
930pub fn unit_detail(unit: &MergeUnitOutcome) -> Text<'static> {
934 let mut lines = vec![
935 Line::from(Span::styled(
936 unit.path.as_str().to_owned(),
937 Style::new().bold(),
938 )),
939 Line::from(vec![
940 badge(
941 disposition_label(unit.disposition),
942 disposition_color(unit.disposition),
943 ),
944 Span::styled(
945 format!(
946 " {} · {} · {}",
947 unit.family,
948 match unit.kind {
949 MergeUnitKind::File => "file",
950 MergeUnitKind::DefinitionModule => "definition module",
951 },
952 unit.strategy
953 ),
954 Style::new().fg(DIM),
955 ),
956 ]),
957 ];
958 if !unit.summary.is_empty() {
959 lines.push(Line::from(""));
960 lines.push(Line::from(unit.summary.clone()));
961 }
962
963 push_section(&mut lines, "Ancestor");
964 let ancestors = unit
965 .contributors
966 .iter()
967 .filter(|contributor| contributor.is_base_game)
968 .collect::<Vec<_>>();
969 if ancestors.is_empty() {
970 lines.push(Line::from(Span::styled(
971 " none: no vanilla definition at this unit",
972 Style::new().fg(DIM),
973 )));
974 }
975 for ancestor in ancestors {
976 lines.push(Line::from(format!(" {}", ancestor.name)));
977 for path in &ancestor.source_paths {
978 lines.push(source_line(path));
979 }
980 }
981
982 push_section(&mut lines, "Contributors (precedence order)");
983 let mut contributors = unit
984 .contributors
985 .iter()
986 .filter(|contributor| !contributor.is_base_game)
987 .collect::<Vec<_>>();
988 contributors.sort_by_key(|contributor| contributor.precedence);
989 for contributor in contributors {
990 lines.push(Line::from(vec![
991 Span::styled(" ▸ ", Style::new().fg(ACCENT)),
992 Span::raw(contributor.name.clone()),
993 Span::styled(
994 format!(
995 " {} · precedence {}",
996 contributor.mod_id, contributor.precedence
997 ),
998 Style::new().fg(DIM),
999 ),
1000 ]));
1001 for path in &contributor.source_paths {
1002 lines.push(source_line(path));
1003 }
1004 }
1005
1006 push_section(&mut lines, "Result");
1007 if unit.output_paths.is_empty() {
1008 lines.push(Line::from(Span::styled(
1009 " no output: this unit would be deferred",
1010 Style::new().fg(DIM),
1011 )));
1012 }
1013 for path in &unit.output_paths {
1014 lines.push(Line::from(format!(" → {}", path.as_str())));
1015 }
1016
1017 if !unit.notes.is_empty() {
1018 push_section(&mut lines, "Notes");
1019 for note in &unit.notes {
1020 lines.push(Line::from(format!(" {note}")));
1021 }
1022 }
1023 Text::from(lines)
1024}
1025
1026fn draw_exclusion_prompt(frame: &mut Frame<'_>, app: &App) {
1027 let excluded = app
1028 .mods()
1029 .iter()
1030 .filter(|playset_mod| app.is_excluded(playset_mod))
1031 .collect::<Vec<_>>();
1032 let mut lines = vec![
1033 Line::from(Span::styled(
1034 format!(
1035 "Analyze {} of {} mods, leaving these {} out?",
1036 app.included_count(),
1037 app.mods().len(),
1038 excluded.len()
1039 ),
1040 Style::new().bold(),
1041 )),
1042 Line::from(Span::styled(
1043 "The result will not represent your full playset. Nothing is changed in the launcher.",
1044 Style::new().fg(DIM),
1045 )),
1046 Line::from(""),
1047 ];
1048 for playset_mod in &excluded {
1049 lines.push(Line::from(vec![
1050 Span::styled(
1051 format!(" #{} {}", playset_mod.position, playset_mod.name),
1052 Style::new().fg(WARN),
1053 ),
1054 Span::styled(format!(" {}", playset_mod.id), Style::new().fg(DIM)),
1055 ]));
1056 lines.push(Line::from(Span::styled(
1057 format!(
1058 " {}",
1059 playset_mod
1060 .source_error
1061 .as_deref()
1062 .map_or_else(|| "excluded by you".to_string(), clean)
1063 ),
1064 Style::new().fg(DIM),
1065 )));
1066 }
1067 lines.push(Line::from(""));
1068 lines.push(Line::from(vec![
1069 key("y"),
1070 Span::raw(" analyze without them "),
1071 key("Esc"),
1072 Span::raw(" cancel"),
1073 ]));
1074 let height = lines.len() as u16 + 2;
1075 let popup = centered(frame.area(), 100, height);
1076 frame.render_widget(Clear, popup);
1077 frame.render_widget(
1078 Paragraph::new(lines)
1079 .wrap(Wrap { trim: false })
1080 .block(panel(" Leave mods out? ", WARN)),
1081 popup,
1082 );
1083}
1084
1085fn draw_options(frame: &mut Frame<'_>, app: &App, cursor: usize) {
1086 let settings = &app.settings;
1087 let check = |on: bool| if on { "■" } else { "□" };
1088 let game_base = format!(
1089 "{} Use the EU4 base as ancestor{}",
1090 check(settings.game_base),
1091 if app.game_base_available {
1092 ""
1093 } else {
1094 " (base data not ready)"
1095 }
1096 );
1097 let rows: [(String, &str); OPTION_COUNT] = [
1098 (game_base, "off = --no-game-base"),
1099 (
1100 format!("{} GUI scroll merge", check(settings.gui_scroll_merge)),
1101 "--gui-scroll-merge",
1102 ),
1103 (
1104 format!(
1105 "{} Ignore replace_path",
1106 check(settings.ignore_replace_path)
1107 ),
1108 "--ignore-replace-path",
1109 ),
1110 (
1111 format!("{} Use supported fallbacks", check(settings.force)),
1112 "--force",
1113 ),
1114 (
1115 format!(" Workers ◀ {} ▶", settings.merge_workers),
1116 "speed only; same output",
1117 ),
1118 ];
1119 let mut lines = rows
1120 .iter()
1121 .enumerate()
1122 .map(|(index, (text, hint))| {
1123 let selected = index == cursor;
1124 let style = if selected {
1125 Style::new().fg(Color::Black).bg(ACCENT).bold()
1126 } else {
1127 Style::new()
1128 };
1129 Line::from(vec![
1130 Span::styled(if selected { "▶ " } else { " " }, Style::new().fg(ACCENT)),
1131 Span::styled(format!("{text:<58}"), style),
1132 Span::styled(format!(" {hint}"), Style::new().fg(DIM)),
1133 ])
1134 })
1135 .collect::<Vec<_>>();
1136 lines.push(Line::from(""));
1137 lines.push(Line::from(Span::styled(
1138 " Options apply to the next analysis and are not saved.",
1139 Style::new().fg(DIM),
1140 )));
1141 lines.push(Line::from(vec![
1142 Span::styled(" CLI ", Style::new().fg(DIM)),
1143 Span::styled(app.cli_command(), Style::new().fg(Color::Gray)),
1144 ]));
1145 lines.push(Line::from(vec![
1146 Span::raw(" "),
1147 key("↑↓"),
1148 Span::raw(" select "),
1149 key("Space"),
1150 Span::raw(" toggle "),
1151 key("←→"),
1152 Span::raw(" workers "),
1153 key("Esc"),
1154 Span::raw(" close"),
1155 ]));
1156 let height = lines.len() as u16 + 2;
1157 let popup = centered(frame.area(), 90, height);
1158 frame.render_widget(Clear, popup);
1159 frame.render_widget(
1160 Paragraph::new(lines).block(panel(" Analysis options ", ACCENT)),
1161 popup,
1162 );
1163}
1164
1165fn footer_keys(app: &App) -> Line<'static> {
1166 let pairs: &[(&str, &str)] = match (&app.phase, app.screen, app.focus) {
1167 _ if app.help => &[("any key", "close")],
1168 _ if app.confirming_exclusions => &[("y", "analyze without them"), ("Esc", "cancel")],
1169 _ if app.confirming_build => &[("y", "build"), ("Esc", "cancel")],
1170 (Phase::Building { .. }, _, _) => &[("q", "quit")],
1171 _ if app.options.is_some() => &[("Space", "toggle"), ("Esc", "close")],
1172 (Phase::Inspecting, _, _) => &[("?", "keys"), ("q", "quit")],
1173 (Phase::Analyzing { .. }, _, _) => &[("?", "keys"), ("q", "cancel and quit")],
1174 (_, _, Focus::Search) => &[
1175 ("type", "filter paths"),
1176 ("Enter", "keep"),
1177 ("Esc", "clear"),
1178 ],
1179 (Phase::Reviewed(_), Screen::Review, _) => &[
1180 ("↑↓", "move"),
1181 ("Tab", "panel"),
1182 ("0-6", "filter"),
1183 ("/", "search"),
1184 ("c", "copy"),
1185 ("i", "playset"),
1186 ("r", "refresh"),
1187 ("?", "keys"),
1188 ("q", "quit"),
1189 ],
1190 _ if app.can_analyze() => &[
1191 ("a", "analyze"),
1192 ("x", "exclude"),
1193 ("R", "repair"),
1194 ("Tab", "panel"),
1195 ("c", "copy"),
1196 ("r", "refresh"),
1197 ("?", "keys"),
1198 ("q", "quit"),
1199 ],
1200 _ if app.selectable => &[
1201 ("X", "exclude broken"),
1202 ("R", "repair all"),
1203 ("x", "exclude"),
1204 ("Tab", "panel"),
1205 ("c", "copy"),
1206 ("r", "refresh"),
1207 ("?", "keys"),
1208 ("q", "quit"),
1209 ],
1210 _ => &[
1211 ("R", "repair"),
1212 ("Tab", "panel"),
1213 ("c", "copy"),
1214 ("r", "refresh"),
1215 ("?", "keys"),
1216 ("q", "quit"),
1217 ],
1218 };
1219 let mut pairs = pairs.to_vec();
1220 let offers_build = matches!(
1221 app.phase,
1222 Phase::Inspected | Phase::Reviewed(_) | Phase::Failed(_)
1223 ) && !app.game_base_available
1224 && app.options.is_none()
1225 && !app.confirming_exclusions
1226 && !app.confirming_build
1227 && !app.help
1228 && app.focus != Focus::Search
1229 && app.data_build_args().is_some();
1230 if offers_build {
1231 pairs.insert(0, ("B", "build base data"));
1232 }
1233 let mut spans = vec![Span::raw(" ")];
1234 for (name, action) in pairs {
1235 spans.push(key(name));
1236 spans.push(Span::styled(format!(" {action} "), Style::new().fg(DIM)));
1237 }
1238 if !app.mouse_capture {
1239 spans.push(Span::styled("mouse released (m)", Style::new().fg(WARN)));
1240 }
1241 Line::from(spans)
1242}
1243
1244fn panel(title: impl Into<Line<'static>>, color: Color) -> Block<'static> {
1245 Block::new()
1246 .borders(Borders::ALL)
1247 .border_type(BorderType::Rounded)
1248 .border_style(Style::new().fg(color))
1249 .title(title)
1250 .title_style(Style::new().fg(color).bold())
1251}
1252
1253fn focus_panel(title: impl Into<String>, color: Color, focused: bool) -> Block<'static> {
1256 let title = title.into();
1257 if focused {
1258 panel(format!(" ▸{title}"), ACCENT).border_type(BorderType::Thick)
1259 } else {
1260 panel(title, color)
1261 }
1262}
1263
1264fn record(app: &App, update: impl FnOnce(&mut HitAreas)) {
1266 let mut hit = app.hit.get();
1267 update(&mut hit);
1268 app.hit.set(hit);
1269}
1270
1271fn key(name: &str) -> Span<'static> {
1272 Span::styled(
1273 format!(" {name} "),
1274 Style::new().fg(Color::Black).bg(ACCENT).bold(),
1275 )
1276}
1277
1278fn chip(text: String, color: Color, active: bool) -> Span<'static> {
1279 if active {
1280 Span::styled(
1281 format!(" {text} "),
1282 Style::new().fg(Color::Black).bg(color).bold(),
1283 )
1284 } else {
1285 Span::styled(format!(" {text} "), Style::new().fg(color))
1286 }
1287}
1288
1289fn badge(text: &str, color: Color) -> Span<'static> {
1290 Span::styled(format!("● {text}"), Style::new().fg(color).bold())
1291}
1292
1293fn field(name: &'static str) -> Span<'static> {
1294 Span::styled(format!("{name:<11}"), Style::new().fg(DIM))
1295}
1296
1297fn push_section(lines: &mut Vec<Line<'static>>, text: &'static str) {
1298 lines.push(Line::from(""));
1299 lines.push(Line::from(Span::styled(
1300 format!("── {text} "),
1301 Style::new().fg(ACCENT).bold(),
1302 )));
1303}
1304
1305fn source_line(path: &str) -> Line<'static> {
1306 Line::from(Span::styled(format!(" {path}"), Style::new().fg(DIM)))
1307}
1308
1309fn clean(text: &str) -> String {
1311 text.replace(r"\\?\", "")
1312}
1313
1314fn display(path: &std::path::Path) -> String {
1316 let text = path.display().to_string();
1317 text.strip_prefix(r"\\?\")
1318 .map_or(text.clone(), str::to_string)
1319}
1320
1321fn centered(area: Rect, width: u16, height: u16) -> Rect {
1322 let width = width.min(area.width);
1323 let height = height.min(area.height);
1324 Rect::new(
1325 area.x + (area.width - width) / 2,
1326 area.y + (area.height - height) / 2,
1327 width,
1328 height,
1329 )
1330}
1331
1332fn short_disposition(disposition: MergeDisposition) -> &'static str {
1333 match disposition {
1334 MergeDisposition::Safe => "SAFE",
1335 MergeDisposition::Copy => "COPY",
1336 MergeDisposition::NeedsUserChoice => "CHOOSE",
1337 MergeDisposition::UnsupportedInput => "UNSUP",
1338 MergeDisposition::EngineFailure => "FAIL",
1339 MergeDisposition::Deferred => "DEFER",
1340 }
1341}
1342
1343fn disposition_color(disposition: MergeDisposition) -> Color {
1344 match disposition {
1345 MergeDisposition::Safe => OK,
1346 MergeDisposition::Copy => Color::Blue,
1347 MergeDisposition::NeedsUserChoice => WARN,
1348 MergeDisposition::UnsupportedInput => Color::Magenta,
1349 MergeDisposition::EngineFailure => BAD,
1350 MergeDisposition::Deferred => DIM,
1351 }
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356 use super::*;
1357 use ratatui::buffer::Buffer;
1358
1359 #[test]
1360 fn a_drag_copies_wide_characters_once_without_panel_borders() {
1361 let mut buffer = Buffer::empty(Rect::new(0, 0, 20, 2));
1362 buffer.set_string(0, 0, "│欧陆扩展 ok │", Style::new());
1363 buffer.set_string(0, 1, "│second line │", Style::new());
1364
1365 let all = buffer.area;
1366 let text = selected_text((Position::new(0, 0), Position::new(19, 1)), all, &buffer);
1367 assert_eq!(text, "欧陆扩展 ok\nsecond line");
1368 let partial = selected_text((Position::new(3, 0), Position::new(5, 1)), all, &buffer);
1369 assert_eq!(partial, "陆扩展 ok\nsecon");
1370 }
1371
1372 #[test]
1375 fn a_drag_copies_only_the_panel_it_started_in() {
1376 let mut buffer = Buffer::empty(Rect::new(0, 0, 24, 2));
1377 buffer.set_string(0, 0, "│left one ││right one│", Style::new());
1378 buffer.set_string(0, 1, "│left two ││right two│", Style::new());
1379
1380 let left = Rect::new(1, 0, 10, 2);
1381 let text = selected_text((Position::new(1, 0), Position::new(10, 1)), left, &buffer);
1382 assert_eq!(text, "left one\nleft two");
1383 }
1384}