Skip to main content

foch_cli/tui/
conflict_handler.rs

1use std::io::{self, IsTerminal, Stdout};
2use std::path::PathBuf;
3
4use crossterm::cursor;
5use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
6use crossterm::execute;
7use crossterm::terminal::{
8	EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
9};
10use ratatui::Frame;
11use ratatui::Terminal;
12use ratatui::backend::CrosstermBackend;
13use ratatui::buffer::Buffer;
14use ratatui::layout::{Constraint, Direction, Layout, Rect};
15use ratatui::style::{Color, Modifier, Style};
16use ratatui::text::Text;
17use ratatui::widgets::{Block, Borders, Clear, Gauge, Paragraph, Widget, Wrap};
18
19use foch::merge::{ConflictDecision, ConflictHandler, ConflictView};
20use foch::model::GamePathBuf;
21
22const ACTION_COUNT: usize = 4;
23const MAX_RENDERED_SUMMARY_LINES: usize = 4;
24const MAX_SNIPPET_LINES: usize = 8;
25const MAX_SNIPPET_LINE_CHARS: usize = 80;
26const MAX_SNIPPET_SECTION_HEIGHT: u16 = 10;
27const MIN_SNIPPET_SECTION_HEIGHT: u16 = 3;
28const NO_VANILLA_SNIPPET: &str = "(no vanilla file at this address)";
29/// Cap how many wrapped lines `reason: …` may consume in the conflict header.
30/// Long sibling-conflict messages that enumerate every divergent value can run
31/// hundreds of characters; capping keeps the candidate list visible on small
32/// terminals while still giving short reasons their natural single-line look.
33const MAX_REASON_HEIGHT: usize = 4;
34
35#[derive(Debug, Clone, Eq, PartialEq)]
36pub enum ConflictAction {
37	PickCandidate(usize),
38	Defer,
39	ExternalFile(PathBuf),
40	KeepExisting,
41	Abort,
42}
43
44#[derive(Debug, Clone, Eq, PartialEq)]
45struct ConflictCandidate {
46	mod_id: String,
47	display_name: String,
48	precedence: usize,
49	summary: Vec<String>,
50	projected_snippet: String,
51}
52
53#[derive(Debug, Clone, Eq, PartialEq)]
54pub struct ConflictResolver {
55	pub current_conflict_index: usize,
56	pub total_conflicts: usize,
57	pub deferred_so_far: usize,
58	file_path: GamePathBuf,
59	address_path: Vec<String>,
60	reason: String,
61	conflict_id: String,
62	vanilla_snippet: Option<String>,
63	candidates: Vec<ConflictCandidate>,
64	selected_index: usize,
65}
66
67pub struct ConflictResolverArgs<'a> {
68	pub view: &'a ConflictView,
69	pub current_idx: usize,
70	pub total: usize,
71	pub deferred_so_far: usize,
72}
73
74impl ConflictResolver {
75	pub fn new(args: ConflictResolverArgs<'_>) -> Self {
76		let ConflictResolverArgs {
77			view,
78			current_idx,
79			total,
80			deferred_so_far,
81		} = args;
82		let candidates: Vec<ConflictCandidate> = view
83			.candidates
84			.iter()
85			.map(|candidate| ConflictCandidate {
86				mod_id: candidate.mod_id.clone(),
87				display_name: candidate.mod_display_name.clone(),
88				precedence: candidate.precedence,
89				summary: candidate.change_summary.clone(),
90				projected_snippet: truncate_rendered_snippet(
91					&candidate.candidate_rendered,
92					MAX_SNIPPET_LINES,
93					MAX_SNIPPET_LINE_CHARS,
94				),
95			})
96			.collect();
97		let mut address_path = view.address_path.clone();
98		if !view.address_key.is_empty() {
99			address_path.push(view.address_key.clone());
100		}
101		// Default the cursor to "Defer" (the first action right after the
102		// candidate list) so an accidental Enter doesn't pick an arbitrary
103		// mod's patch. Users can still arrow up to candidates explicitly.
104		let default_selected = candidates.len();
105		Self {
106			current_conflict_index: current_idx,
107			total_conflicts: total,
108			deferred_so_far,
109			file_path: view.file_path.clone(),
110			address_path,
111			reason: view.reason.clone(),
112			conflict_id: view.conflict_id.clone(),
113			vanilla_snippet: view.vanilla_snippet.clone().map(|snippet| {
114				truncate_rendered_snippet(&snippet, MAX_SNIPPET_LINES, MAX_SNIPPET_LINE_CHARS)
115			}),
116			candidates,
117			selected_index: default_selected,
118		}
119	}
120
121	pub fn render(&self, frame: &mut Frame<'_>, area: Rect) {
122		frame.render_widget(self, area);
123	}
124
125	pub fn handle_key(&mut self, key: KeyEvent) -> Option<ConflictAction> {
126		match key.code {
127			KeyCode::Up => {
128				self.selected_index = self.selected_index.saturating_sub(1);
129				None
130			}
131			KeyCode::Down => {
132				let last = self.item_count().saturating_sub(1);
133				self.selected_index = self.selected_index.saturating_add(1).min(last);
134				None
135			}
136			KeyCode::Home => {
137				self.selected_index = 0;
138				None
139			}
140			KeyCode::End => {
141				self.selected_index = self.item_count().saturating_sub(1);
142				None
143			}
144			KeyCode::Enter => Some(self.action_for_index(self.selected_index)),
145			KeyCode::Esc => Some(ConflictAction::Defer),
146			KeyCode::Char(value) => self.handle_char(value),
147			_ => None,
148		}
149	}
150
151	fn handle_char(&mut self, value: char) -> Option<ConflictAction> {
152		match value {
153			'q' | 'Q' => Some(ConflictAction::Abort),
154			'd' | 'D' => Some(ConflictAction::Defer),
155			's' | 'S' => Some(ConflictAction::ExternalFile(PathBuf::new())),
156			'k' | 'K' => Some(ConflictAction::KeepExisting),
157			'1'..='9' => {
158				let index = value.to_digit(10).expect("digit matched") as usize - 1;
159				(index < self.candidates.len()).then_some(ConflictAction::PickCandidate(index))
160			}
161			_ => None,
162		}
163	}
164
165	fn item_count(&self) -> usize {
166		self.candidates.len() + ACTION_COUNT
167	}
168
169	fn action_for_index(&self, index: usize) -> ConflictAction {
170		if index < self.candidates.len() {
171			return ConflictAction::PickCandidate(index);
172		}
173		match index - self.candidates.len() {
174			0 => ConflictAction::Defer,
175			1 => ConflictAction::ExternalFile(PathBuf::new()),
176			2 => ConflictAction::KeepExisting,
177			_ => ConflictAction::Abort,
178		}
179	}
180}
181
182impl Widget for &ConflictResolver {
183	fn render(self, area: Rect, buf: &mut Buffer) {
184		Clear.render(area, buf);
185		let title = format!(
186			" foch merge: conflict {}/{} ",
187			self.current_conflict_index, self.total_conflicts
188		);
189		Block::default()
190			.title(title)
191			.borders(Borders::ALL)
192			.render(area, buf);
193		if area.width < 4 || area.height < 4 {
194			return;
195		}
196
197		let inner = Rect {
198			x: area.x + 1,
199			y: area.y + 1,
200			width: area.width.saturating_sub(2),
201			height: area.height.saturating_sub(2),
202		};
203		let hint_y = inner.y + inner.height.saturating_sub(1);
204		let bottom_separator_y = hint_y.saturating_sub(1);
205
206		// Header rows: progress gauge (1 row), file path (1 row), address
207		// path (1 row), blank (1 row), reason (variable rows — word-wrapped
208		// so long messages don't get truncated by the right border). Compute
209		// reason height from the available inner width with a hard cap so the
210		// choice list always has at least a couple of rows on small terminals.
211		let reason_text = format!("reason: {}", self.reason);
212		let reason_paragraph = Paragraph::new(Text::raw(reason_text.clone()))
213			.style(Style::default().fg(Color::Yellow))
214			.wrap(Wrap { trim: false });
215		let reason_height =
216			estimate_wrapped_line_count(&reason_text, inner.width).clamp(1, MAX_REASON_HEIGHT);
217		let header_height = 4u16.saturating_add(reason_height as u16);
218		let top_separator_y = inner
219			.y
220			.saturating_add(header_height)
221			.min(bottom_separator_y);
222
223		let gauge_rect = Rect {
224			x: inner.x,
225			y: inner.y,
226			width: inner.width,
227			height: 1,
228		};
229		render_progress_gauge(
230			gauge_rect,
231			buf,
232			self.current_conflict_index,
233			self.total_conflicts,
234			self.deferred_so_far,
235		);
236		write_line(
237			buf,
238			inner,
239			inner.y.saturating_add(1),
240			self.file_path.as_str(),
241			Style::default(),
242		);
243		write_line(
244			buf,
245			inner,
246			inner.y.saturating_add(2),
247			&format!("  {}", self.address_path.join(" / ")),
248			Style::default().fg(Color::Cyan),
249		);
250		// reason starts at inner.y + 4, claims `reason_height` rows of the
251		// header band so the rest of the layout flows below it.
252		let reason_y = inner.y.saturating_add(4);
253		let reason_rect = Rect {
254			x: inner.x,
255			y: reason_y,
256			width: inner.width,
257			height: top_separator_y.saturating_sub(reason_y),
258		};
259		if reason_rect.height > 0 {
260			reason_paragraph.render(reason_rect, buf);
261		}
262		draw_separator(buf, area, top_separator_y);
263
264		if bottom_separator_y > top_separator_y {
265			let list_area = Rect {
266				x: inner.x,
267				y: top_separator_y + 1,
268				width: inner.width,
269				height: bottom_separator_y.saturating_sub(top_separator_y + 1),
270			};
271			render_context_sections_and_actions(self, buf, list_area);
272		}
273
274		draw_separator(buf, area, bottom_separator_y);
275		write_line(
276			buf,
277			inner,
278			hint_y,
279			"↑↓ select  Enter confirm  Esc/d defer  Q abort  S file  K keep",
280			Style::default().fg(Color::DarkGray),
281		);
282	}
283}
284
285pub struct InteractiveTuiHandler {
286	current_conflict_index: usize,
287	total_conflicts: usize,
288	deferred_so_far: usize,
289}
290
291impl InteractiveTuiHandler {
292	pub fn new() -> Self {
293		Self {
294			current_conflict_index: 1,
295			total_conflicts: 1,
296			deferred_so_far: 0,
297		}
298	}
299
300	fn stdin_stdout_are_tty(&self) -> bool {
301		io::stdin().is_terminal() && io::stdout().is_terminal()
302	}
303}
304
305impl Default for InteractiveTuiHandler {
306	fn default() -> Self {
307		Self::new()
308	}
309}
310
311impl ConflictHandler for InteractiveTuiHandler {
312	fn on_conflict(&mut self, view: &ConflictView) -> ConflictDecision {
313		if !self.stdin_stdout_are_tty() {
314			eprintln!(
315				"[foch] interactive TUI could not be entered because stdin/stdout is not a TTY; downgrading to defer"
316			);
317			return ConflictDecision::Defer { record: None };
318		}
319
320		let mut resolver = ConflictResolver::new(ConflictResolverArgs {
321			view,
322			current_idx: self.current_conflict_index,
323			total: self.total_conflicts,
324			deferred_so_far: self.deferred_so_far,
325		});
326
327		let action = match run_resolver(&mut resolver) {
328			Ok(action) => action,
329			Err(err) => {
330				eprintln!("[foch] interactive TUI failed: {err}; downgrading to defer");
331				return ConflictDecision::Defer { record: None };
332			}
333		};
334
335		match action {
336			ConflictAction::PickCandidate(candidate) => view
337				.candidates
338				.get(candidate)
339				.map(|_| ConflictDecision::PickCandidate {
340					candidate,
341					record: None,
342				})
343				.unwrap_or(ConflictDecision::Defer { record: None }),
344			ConflictAction::Defer => ConflictDecision::Defer { record: None },
345			ConflictAction::ExternalFile(path) => {
346				if path.as_os_str().is_empty() {
347					ConflictDecision::Defer { record: None }
348				} else {
349					ConflictDecision::UseFile(path)
350				}
351			}
352			ConflictAction::KeepExisting => ConflictDecision::KeepExisting,
353			ConflictAction::Abort => ConflictDecision::Abort,
354		}
355	}
356
357	fn set_conflict_progress(&mut self, current: usize, total: usize) {
358		self.current_conflict_index = current;
359		self.total_conflicts = total;
360	}
361
362	fn set_deferred_so_far(&mut self, count: usize) {
363		self.deferred_so_far = count;
364	}
365}
366
367fn run_resolver(resolver: &mut ConflictResolver) -> io::Result<ConflictAction> {
368	enable_raw_mode()?;
369	let _guard = TerminalGuard;
370	let mut stdout = io::stdout();
371	execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
372	let backend = CrosstermBackend::new(stdout);
373	let mut terminal = Terminal::new(backend)?;
374
375	// Drain any stale events queued before we entered raw mode (typically a
376	// stray Enter left over from the shell-cooked overwrite prompt earlier
377	// in the merge command, which would otherwise be read instantly as the
378	// first keypress and dismiss the TUI before it's even visible).
379	while event::poll(std::time::Duration::ZERO)? {
380		let _ = event::read()?;
381	}
382
383	loop {
384		terminal.draw(|frame| resolver.render(frame, frame.area()))?;
385		if let Event::Key(key) = event::read()? {
386			let Some(action) = resolver.handle_key(key) else {
387				continue;
388			};
389			if matches!(action, ConflictAction::ExternalFile(ref path) if path.as_os_str().is_empty())
390			{
391				if let Some(path) = prompt_external_file(&mut terminal, resolver)? {
392					return Ok(ConflictAction::ExternalFile(path));
393				}
394				continue;
395			}
396			return Ok(action);
397		}
398	}
399}
400
401struct TerminalGuard;
402
403impl Drop for TerminalGuard {
404	fn drop(&mut self) {
405		let _ = disable_raw_mode();
406		let mut stdout = io::stdout();
407		let _ = execute!(stdout, cursor::Show, LeaveAlternateScreen);
408	}
409}
410
411fn prompt_external_file(
412	terminal: &mut Terminal<CrosstermBackend<Stdout>>,
413	resolver: &ConflictResolver,
414) -> io::Result<Option<PathBuf>> {
415	let mut input = String::new();
416	loop {
417		terminal.draw(|frame| {
418			resolver.render(frame, frame.area());
419			render_external_file_dialog(frame, &input);
420		})?;
421		if let Event::Key(key) = event::read()? {
422			match key.code {
423				KeyCode::Enter => {
424					let path = input.trim();
425					if !path.is_empty() {
426						return Ok(Some(PathBuf::from(path)));
427					}
428				}
429				KeyCode::Esc => return Ok(None),
430				KeyCode::Backspace => {
431					input.pop();
432				}
433				KeyCode::Char(value)
434					if !key
435						.modifiers
436						.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
437				{
438					input.push(value);
439				}
440				_ => {}
441			}
442		}
443	}
444}
445
446fn render_external_file_dialog(frame: &mut Frame<'_>, input: &str) {
447	let area = centered_rect(70, 30, frame.area());
448	frame.render_widget(Clear, area);
449	let block = Block::default()
450		.title(" external resolution file ")
451		.borders(Borders::ALL);
452	let chunks = Layout::default()
453		.direction(Direction::Vertical)
454		.constraints([
455			Constraint::Length(1),
456			Constraint::Length(1),
457			Constraint::Length(1),
458		])
459		.margin(1)
460		.split(area);
461	frame.render_widget(block, area);
462	frame.render_widget(
463		Paragraph::new("Path to a complete replacement file (Esc cancels):"),
464		chunks[0],
465	);
466	frame.render_widget(
467		Paragraph::new(input.to_string()).style(Style::default().fg(Color::Cyan)),
468		chunks[1],
469	);
470	frame.render_widget(
471		Paragraph::new("Enter confirm  Esc back").style(Style::default().fg(Color::DarkGray)),
472		chunks[2],
473	);
474}
475
476fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
477	let vertical = Layout::default()
478		.direction(Direction::Vertical)
479		.constraints([
480			Constraint::Percentage((100 - percent_y) / 2),
481			Constraint::Percentage(percent_y),
482			Constraint::Percentage((100 - percent_y) / 2),
483		])
484		.split(area);
485	let horizontal = Layout::default()
486		.direction(Direction::Horizontal)
487		.constraints([
488			Constraint::Percentage((100 - percent_x) / 2),
489			Constraint::Percentage(percent_x),
490			Constraint::Percentage((100 - percent_x) / 2),
491		])
492		.split(vertical[1]);
493	horizontal[1]
494}
495
496fn render_context_sections_and_actions(resolver: &ConflictResolver, buf: &mut Buffer, area: Rect) {
497	if area.height == 0 {
498		return;
499	}
500
501	let action_height = (ACTION_COUNT as u16 + 1).min(area.height);
502	let sections_height = area.height.saturating_sub(action_height);
503	if sections_height > 0 {
504		let sections_area = Rect {
505			x: area.x,
506			y: area.y,
507			width: area.width,
508			height: sections_height,
509		};
510		render_snippet_sections(resolver, buf, sections_area);
511	}
512
513	if action_height > 0 {
514		let action_area = Rect {
515			x: area.x,
516			y: area.y + sections_height,
517			width: area.width,
518			height: action_height,
519		};
520		render_action_rows(resolver, buf, action_area);
521	}
522}
523
524#[derive(Debug)]
525struct SnippetSection {
526	title: String,
527	body: String,
528	body_style: Style,
529	border_style: Style,
530	height: u16,
531}
532
533fn render_snippet_sections(resolver: &ConflictResolver, buf: &mut Buffer, area: Rect) {
534	if area.height == 0 {
535		return;
536	}
537
538	let mut sections = Vec::new();
539	let mut remaining = area.height;
540	let mut hidden_candidates = 0usize;
541
542	if remaining >= MIN_SNIPPET_SECTION_HEIGHT {
543		let vanilla_absent = resolver.vanilla_snippet.is_none();
544		let body = resolver
545			.vanilla_snippet
546			.clone()
547			.unwrap_or_else(|| NO_VANILLA_SNIPPET.to_string());
548		let height = snippet_section_height(&body).min(remaining);
549		sections.push(SnippetSection {
550			title: "vanilla baseline".to_string(),
551			body,
552			body_style: if vanilla_absent {
553				Style::default().fg(Color::DarkGray)
554			} else {
555				Style::default()
556			},
557			border_style: Style::default(),
558			height,
559		});
560		remaining = remaining.saturating_sub(height);
561	} else {
562		hidden_candidates = resolver.candidates.len();
563	}
564
565	if hidden_candidates == 0 {
566		for (index, candidate) in resolver.candidates.iter().enumerate() {
567			if remaining < MIN_SNIPPET_SECTION_HEIGHT {
568				hidden_candidates = resolver.candidates.len().saturating_sub(index);
569				break;
570			}
571
572			let desired_height = snippet_section_height(&candidate.projected_snippet);
573			if index + 1 < resolver.candidates.len() && remaining <= desired_height {
574				hidden_candidates = resolver.candidates.len().saturating_sub(index);
575				break;
576			}
577
578			let selected = resolver.selected_index == index;
579			let height = desired_height.min(remaining);
580			sections.push(SnippetSection {
581				title: format!(
582					"[{}] {} ({}, prec {})",
583					index + 1,
584					candidate.display_name,
585					candidate.mod_id,
586					candidate.precedence
587				),
588				body: candidate.projected_snippet.clone(),
589				body_style: Style::default(),
590				border_style: if selected {
591					Style::default()
592						.fg(Color::Cyan)
593						.add_modifier(Modifier::BOLD)
594				} else {
595					Style::default()
596				},
597				height,
598			});
599			remaining = remaining.saturating_sub(height);
600		}
601	}
602
603	let hidden_line_height = u16::from(hidden_candidates > 0 && remaining > 0);
604	let layout_height = sections
605		.iter()
606		.map(|section| section.height)
607		.sum::<u16>()
608		.saturating_add(hidden_line_height);
609	if layout_height == 0 {
610		return;
611	}
612
613	let constraints = sections
614		.iter()
615		.map(|section| Constraint::Min(section.height))
616		.chain((hidden_line_height > 0).then_some(Constraint::Length(1)))
617		.collect::<Vec<_>>();
618	let layout_area = Rect {
619		x: area.x,
620		y: area.y,
621		width: area.width,
622		height: layout_height.min(area.height),
623	};
624	let chunks = Layout::default()
625		.direction(Direction::Vertical)
626		.constraints(constraints)
627		.split(layout_area);
628
629	for (section, chunk) in sections.iter().zip(chunks.iter()) {
630		render_snippet_section(section, buf, *chunk);
631	}
632	if hidden_candidates > 0
633		&& hidden_line_height > 0
634		&& let Some(chunk) = chunks.get(sections.len())
635	{
636		write_line(
637			buf,
638			*chunk,
639			chunk.y,
640			&format!("({hidden_candidates} more candidates not shown — resize terminal)"),
641			Style::default().fg(Color::DarkGray),
642		);
643	}
644}
645
646fn snippet_section_height(snippet: &str) -> u16 {
647	let line_count = snippet.lines().count().max(1) as u16;
648	line_count
649		.saturating_add(2)
650		.clamp(MIN_SNIPPET_SECTION_HEIGHT, MAX_SNIPPET_SECTION_HEIGHT)
651}
652
653fn render_snippet_section(section: &SnippetSection, buf: &mut Buffer, area: Rect) {
654	if area.width == 0 || area.height == 0 {
655		return;
656	}
657	let block = Block::default()
658		.title(format!(" {} ", section.title))
659		.borders(Borders::ALL)
660		.border_style(section.border_style);
661	let inner = block.inner(area);
662	block.render(area, buf);
663	if inner.width == 0 || inner.height == 0 {
664		return;
665	}
666	Paragraph::new(section.body.clone())
667		.style(section.body_style)
668		.render(inner, buf);
669}
670
671fn render_action_rows(resolver: &ConflictResolver, buf: &mut Buffer, area: Rect) {
672	for (row, line) in action_choice_lines(resolver)
673		.into_iter()
674		.take(area.height as usize)
675		.enumerate()
676	{
677		write_line(buf, area, area.y + row as u16, &line.text, line.style);
678	}
679}
680
681#[allow(dead_code)]
682fn render_choice_list(resolver: &ConflictResolver, buf: &mut Buffer, area: Rect) {
683	let lines = choice_lines(resolver);
684	let selected_line = lines
685		.iter()
686		.position(|line| line.item_index == Some(resolver.selected_index))
687		.unwrap_or(0);
688	let scroll = selected_line.saturating_sub(area.height.saturating_sub(1) as usize);
689	for (row, line) in lines
690		.iter()
691		.skip(scroll)
692		.take(area.height as usize)
693		.enumerate()
694	{
695		write_line(buf, area, area.y + row as u16, &line.text, line.style);
696	}
697}
698
699struct ChoiceLine {
700	item_index: Option<usize>,
701	text: String,
702	style: Style,
703}
704
705fn choice_lines(resolver: &ConflictResolver) -> Vec<ChoiceLine> {
706	let selected_style = Style::default()
707		.fg(Color::Yellow)
708		.add_modifier(Modifier::BOLD);
709	let mut lines = Vec::new();
710	for (index, candidate) in resolver.candidates.iter().enumerate() {
711		let selected = resolver.selected_index == index;
712		let marker = if selected { "❯" } else { " " };
713		lines.push(ChoiceLine {
714			item_index: Some(index),
715			text: format!(
716				"{marker} [{}] {} ({}, prec {})",
717				index + 1,
718				candidate.display_name,
719				candidate.mod_id,
720				candidate.precedence
721			),
722			style: if selected {
723				selected_style
724			} else {
725				Style::default()
726			},
727		});
728		for summary_line in rendered_summary_lines(&candidate.summary) {
729			lines.push(ChoiceLine {
730				item_index: None,
731				text: format!("      {summary_line}"),
732				style: Style::default().fg(Color::Gray),
733			});
734		}
735	}
736	lines.extend(action_choice_lines(resolver));
737	lines
738}
739
740fn action_choice_lines(resolver: &ConflictResolver) -> Vec<ChoiceLine> {
741	let selected_style = Style::default()
742		.fg(Color::Yellow)
743		.add_modifier(Modifier::BOLD);
744	let mut lines = vec![ChoiceLine {
745		item_index: None,
746		text: "  ─────".to_string(),
747		style: Style::default().fg(Color::DarkGray),
748	}];
749	for (offset, label) in [
750		"[d] defer (skip; record in report)",
751		"[s] use external file (paste a path)",
752		"[k] keep existing file in out dir (don't overwrite)",
753		"[q] abort merge",
754	]
755	.iter()
756	.enumerate()
757	{
758		let index = resolver.candidates.len() + offset;
759		let selected = resolver.selected_index == index;
760		let marker = if selected { "❯" } else { " " };
761		lines.push(ChoiceLine {
762			item_index: Some(index),
763			text: format!("{marker} {label}"),
764			style: if selected {
765				selected_style
766			} else {
767				Style::default()
768			},
769		});
770	}
771	lines
772}
773
774fn rendered_summary_lines(summary: &[String]) -> Vec<String> {
775	if summary.len() <= MAX_RENDERED_SUMMARY_LINES {
776		return summary.to_vec();
777	}
778
779	let keep_prefix = MAX_RENDERED_SUMMARY_LINES.saturating_sub(1);
780	let mut rendered: Vec<String> = summary.iter().take(keep_prefix).cloned().collect();
781	let hidden_between = summary.len().saturating_sub(keep_prefix + 1);
782	let overflow_line = summary
783		.last()
784		.and_then(|line| adjusted_more_line(line, hidden_between))
785		.unwrap_or_else(|| "  …".to_string());
786	rendered.push(overflow_line);
787	rendered
788}
789
790fn adjusted_more_line(line: &str, hidden_between: usize) -> Option<String> {
791	for prefix in ["  + … (", "  - … ("] {
792		let Some(rest) = line.strip_prefix(prefix) else {
793			continue;
794		};
795		let Some(count) = rest.strip_suffix(" more)") else {
796			continue;
797		};
798		let count = count.parse::<usize>().ok()?;
799		return Some(format!("{prefix}{} more)", count + hidden_between));
800	}
801	None
802}
803
804fn draw_separator(buf: &mut Buffer, area: Rect, y: u16) {
805	if y <= area.y || y >= area.y.saturating_add(area.height).saturating_sub(1) || area.width < 2 {
806		return;
807	}
808	let right = area.x + area.width - 1;
809	buf[(area.x, y)].set_symbol("├");
810	for x in area.x + 1..right {
811		buf[(x, y)].set_symbol("─");
812	}
813	buf[(right, y)].set_symbol("┤");
814}
815
816fn write_line(buf: &mut Buffer, area: Rect, y: u16, text: &str, style: Style) {
817	if y < area.y || y >= area.y.saturating_add(area.height) || area.width == 0 {
818		return;
819	}
820	buf.set_stringn(area.x, y, text, area.width as usize, style);
821}
822
823fn render_progress_gauge(
824	area: Rect,
825	buf: &mut Buffer,
826	current: usize,
827	total: usize,
828	deferred_so_far: usize,
829) {
830	if area.width == 0 || area.height == 0 {
831		return;
832	}
833	let total = total.max(1);
834	let current = current.min(total);
835	let ratio = current as f64 / total as f64;
836	let percent = (ratio * 100.0).round() as u16;
837	let label = format!("conflict {current}/{total}  ({percent}%, {deferred_so_far} deferred)");
838	Gauge::default()
839		.gauge_style(Style::default().fg(Color::Cyan).bg(Color::DarkGray))
840		.ratio(ratio.clamp(0.0, 1.0))
841		.label(label)
842		.render(area, buf);
843}
844
845/// Conservative estimate of how many terminal rows a `Paragraph::wrap`
846/// rendering of `text` will consume at `width` columns. Counts each `\n`
847/// as a hard break and divides each segment by `width` rounded up. Uses
848/// `chars().count()` for the per-segment length: this overcounts CJK
849/// (full-width) glyphs that ratatui would render as 2 cells, which is the
850/// safe direction — we'd rather reserve a row that ends up blank than
851/// truncate the wrap.
852fn estimate_wrapped_line_count(text: &str, width: u16) -> usize {
853	if width == 0 {
854		return 1;
855	}
856	let width = width as usize;
857	let mut total = 0usize;
858	for segment in text.split('\n') {
859		let chars = segment.chars().count();
860		let segment_lines = if chars == 0 { 1 } else { chars.div_ceil(width) };
861		total = total.saturating_add(segment_lines);
862	}
863	total.max(1)
864}
865
866fn truncate_rendered_snippet(input: &str, max_lines: usize, max_chars: usize) -> String {
867	if max_lines == 0 {
868		return String::new();
869	}
870	// ratatui's Paragraph widget renders tab characters as zero-width on most
871	// terminals, so engine-rendered Clausewitz text with tab indentation shows
872	// up flat. Expand tabs to 2 spaces so the snippet structure is preserved.
873	let expanded = input.replace('\t', "  ");
874	let trimmed = expanded.trim_end_matches(['\r', '\n']);
875	let lines = trimmed.lines().collect::<Vec<_>>();
876	if lines.is_empty() {
877		return String::new();
878	}
879
880	let exceeds_lines = lines.len() > max_lines;
881	let keep_lines = if exceeds_lines {
882		max_lines.saturating_sub(1)
883	} else {
884		lines.len()
885	};
886	let mut rendered = lines
887		.iter()
888		.take(keep_lines)
889		.map(|line| truncate_snippet_line(line, max_chars))
890		.collect::<Vec<_>>();
891	if exceeds_lines {
892		rendered.push(format!(
893			"… ({} more lines)",
894			lines.len().saturating_sub(keep_lines)
895		));
896	}
897	rendered.join("\n")
898}
899
900fn truncate_snippet_line(line: &str, max_chars: usize) -> String {
901	if max_chars == 0 {
902		return "…".to_string();
903	}
904	if line.chars().count() <= max_chars {
905		return line.to_string();
906	}
907	let truncated = line
908		.chars()
909		.take(max_chars.saturating_sub(1))
910		.collect::<String>();
911	format!("{truncated}…")
912}
913
914#[cfg(test)]
915mod tests {
916	use super::*;
917	use foch::merge::CandidateView;
918
919	fn sample_view() -> ConflictView {
920		ConflictView {
921			file_path: GamePathBuf::parse("events/FlavorFRA.txt").expect("valid game path"),
922			address_path: vec![
923				"flavor_fra.3135".to_string(),
924				"option".to_string(),
925				"define_advisor".to_string(),
926			],
927			address_key: "name".to_string(),
928			conflict_id: "conflict-id".to_string(),
929			reason: "sibling mods set the same scalar to divergent values".to_string(),
930			vanilla_snippet: Some("name = \"old\"\n".to_string()),
931			candidates: vec![
932				CandidateView {
933					mod_id: "2164202838".to_string(),
934					mod_display_name: "Europa Expanded".to_string(),
935					precedence: 0,
936					change_summary: vec![
937						"set \"name\": \"old\" → \"Charles-Francois de Broglie\"".to_string(),
938					],
939					candidate_rendered: "name = \"Charles-Francois de Broglie\"\n".to_string(),
940				},
941				CandidateView {
942					mod_id: "1999055990".to_string(),
943					mod_display_name: "Chinese Language Supp.".to_string(),
944					precedence: 2,
945					change_summary: vec![
946						"set \"name\": \"old\" → \"Chinese localization\"".to_string(),
947					],
948					candidate_rendered: "name = \"Chinese localization\"\n".to_string(),
949				},
950			],
951		}
952	}
953
954	fn buffer_lines(buffer: &Buffer) -> Vec<String> {
955		(0..buffer.area.height)
956			.map(|y| {
957				(0..buffer.area.width)
958					.map(|x| buffer[(buffer.area.x + x, buffer.area.y + y)].symbol())
959					.collect::<String>()
960			})
961			.collect()
962	}
963
964	fn assert_line_contains(lines: &[String], expected: &str) {
965		assert!(
966			lines.iter().any(|line| line.contains(expected)),
967			"expected a rendered line to contain {expected:?}; got:\n{}",
968			lines.join("\n")
969		);
970	}
971
972	#[test]
973	fn render_conflict_resolver_into_buffer() {
974		let view = sample_view();
975		let resolver = ConflictResolver::new(ConflictResolverArgs {
976			view: &view,
977			current_idx: 1,
978			total: 30,
979			deferred_so_far: 0,
980		});
981		let area = Rect::new(0, 0, 76, 30);
982		let mut actual = Buffer::empty(area);
983		Widget::render(&resolver, area, &mut actual);
984		let actual_lines = buffer_lines(&actual);
985
986		assert_eq!(
987			actual_lines[0],
988			"┌ foch merge: conflict 1/30 ───────────────────────────────────────────────┐"
989		);
990		let gauge_line = actual_lines[1].as_str();
991		assert!(
992			gauge_line.contains("conflict 1/30"),
993			"gauge missing label: {gauge_line:?}"
994		);
995		assert_line_contains(&actual_lines, "name = \"old\"");
996		assert_line_contains(&actual_lines, "[1] Europa Expanded");
997		assert_line_contains(&actual_lines, "[2] Chinese Language Supp.");
998		assert_line_contains(&actual_lines, "name = \"Charles-Francois de Broglie\"");
999		assert_line_contains(&actual_lines, "name = \"Chinese localization\"");
1000		assert_line_contains(&actual_lines, "[d] defer (skip; record in report)");
1001		assert_line_contains(&actual_lines, "[s] use external file (paste a path)");
1002		assert_line_contains(
1003			&actual_lines,
1004			"[k] keep existing file in out dir (don't overwrite)",
1005		);
1006		assert_line_contains(&actual_lines, "[q] abort merge");
1007	}
1008
1009	#[test]
1010	fn render_conflict_resolver_without_vanilla_shows_placeholder() {
1011		let mut view = sample_view();
1012		view.vanilla_snippet = None;
1013		let resolver = ConflictResolver::new(ConflictResolverArgs {
1014			view: &view,
1015			current_idx: 1,
1016			total: 2,
1017			deferred_so_far: 0,
1018		});
1019		let area = Rect::new(0, 0, 76, 30);
1020		let mut actual = Buffer::empty(area);
1021		Widget::render(&resolver, area, &mut actual);
1022
1023		assert_line_contains(&buffer_lines(&actual), NO_VANILLA_SNIPPET);
1024	}
1025
1026	#[test]
1027	fn rendered_summary_lines_collapses_middle_overflow() {
1028		let lines = rendered_summary_lines(&[
1029			"one".to_string(),
1030			"two".to_string(),
1031			"three".to_string(),
1032			"  + … (2 more)".to_string(),
1033		]);
1034
1035		assert_eq!(
1036			lines,
1037			vec![
1038				"one".to_string(),
1039				"two".to_string(),
1040				"three".to_string(),
1041				"  + … (2 more)".to_string(),
1042			]
1043		);
1044	}
1045}