Skip to main content

foch_cli/tui/browser/
render.rs

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
93/// The cells from one corner of a drag to the other, in reading order within
94/// `area` (the panel the drag started in): whole panel rows between the
95/// first and the last.
96fn 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
117/// The text a drag covered in a drawn frame: wide characters once, panel
118/// borders and trailing blanks dropped.
119pub 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
385/// What the user can do next, stated before anything else on the screen.
386fn 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	// The name and id always show; the version gives way on narrow screens.
527	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
930/// Everything the analysis knows about one unit: the vanilla ancestor, every
931/// contributor in precedence order with its source files, and the outputs
932/// the analysis would commit.
933pub 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
1253/// A panel that takes the keyboard when focused: thick accent border, and a
1254/// `▸` before the title.
1255fn 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
1264/// Remember where this frame drew a panel, for mouse handling.
1265fn 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
1309/// Text for display, without Windows' verbatim `\\?\` path prefix.
1310fn clean(text: &str) -> String {
1311	text.replace(r"\\?\", "")
1312}
1313
1314/// A path for display, without Windows' verbatim `\\?\` prefix.
1315fn 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	/// A selection confined to one panel copies only that panel's columns,
1373	/// row by row, even where a neighbouring panel shares the rows.
1374	#[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}