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)";
29const 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 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 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 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 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
845fn 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 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}