use crate::i18n::{tr, tr_f};
use std::ops::Range;
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, BorderType, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
};
use crate::app::{FileMerge, SideState};
use super::highlight::{FileHighlight, PaneSyntax};
use super::rows::{Cell, ChangeType, Row};
use super::theme::Theme;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Pane {
Local,
Result,
Remote,
}
struct CellBudget {
gutter: bool,
no_width: usize,
}
fn cell_budget(inner: u16, max_no: usize) -> CellBudget {
let no_width = max_no.to_string().len().max(3);
if inner >= 24 {
CellBudget {
gutter: true,
no_width,
}
} else if inner >= 14 {
CellBudget {
gutter: true,
no_width: 0,
}
} else {
CellBudget {
gutter: false,
no_width: 0,
}
}
}
fn gutter_symbol(row: &Row, pane: Pane, theme: &Theme) -> Span<'static> {
let sym = |state: Option<SideState>, arrow: &'static str| match state {
Some(SideState::Pending) if !row.resolved => {
Span::styled(arrow, Style::new().fg(theme.accent(row.change)))
}
Some(SideState::Applied) => Span::styled("✓", Style::new().fg(theme.green)),
Some(SideState::Ignored) => Span::styled("✗", Style::new().fg(theme.fg_dim)),
_ => Span::raw(" "),
};
match pane {
Pane::Local => sym(row.ours_state, "»"),
Pane::Remote => sym(row.theirs_state, "«"),
Pane::Result => Span::raw(" "),
}
}
fn pane_title(pane: Pane, merge: &FileMerge) -> String {
match pane {
Pane::Local => merge
.ours_label
.as_deref()
.map_or(" LOCAL (ours) ".to_owned(), |l| format!(" LOCAL · {l} ")),
Pane::Result => " RESULT ".to_owned(),
Pane::Remote => merge
.theirs_label
.as_deref()
.map_or(" REMOTE (theirs) ".to_owned(), |l| {
format!(" REMOTE · {l} ")
}),
}
}
fn fold_line(n: usize, pane: Pane, inner: u16, theme: &Theme) -> Line<'static> {
let style = Style::new().fg(theme.border);
if pane != Pane::Result {
return Line::styled("─".repeat(inner as usize), style);
}
let text = if inner >= 30 {
tr_f("ui.fold", &[("n", &n.to_string())])
} else if inner >= 12 {
format!("─ ⋯ {n} ⋯ ─")
} else {
"─".repeat(inner as usize)
};
Line::styled(text, style).alignment(Alignment::Center)
}
fn compose_spans(
text: &str,
fg_spans: &[(Color, Range<usize>)],
emphasis: &[Range<usize>],
emph_bg: Color,
) -> Vec<Span<'static>> {
let mut bounds = vec![0, text.len()];
for r in fg_spans.iter().map(|(_, r)| r).chain(emphasis) {
bounds.push(r.start.min(text.len()));
bounds.push(r.end.min(text.len()));
}
bounds.sort_unstable();
bounds.dedup();
let mut spans = Vec::with_capacity(bounds.len());
for win in bounds.windows(2) {
let (a, b) = (win[0], win[1]);
let mut style = Style::new();
if let Some((color, _)) = fg_spans.iter().find(|(_, r)| r.start <= a && b <= r.end) {
style = style.fg(*color);
}
if emphasis.iter().any(|r| r.start <= a && b <= r.end) {
style = style.bg(emph_bg);
}
spans.push(Span::styled(text[a..b].to_owned(), style));
}
spans
}
type LineLayers<'a> = (&'a [Range<usize>], &'a [(Color, Range<usize>)]);
fn cell_line(
row: &Row,
current: bool,
cell: &Cell,
pane: Pane,
budget: &CellBudget,
theme: &Theme,
(emphasis, fg_spans): LineLayers<'_>,
) -> Line<'static> {
let mut style = Style::new();
let banded = match pane {
Pane::Local => row.ours_state.is_some(),
Pane::Remote => row.theirs_state.is_some(),
Pane::Result => true,
};
if banded
&& !row.resolved
&& let Some(bg) = theme.band_bg(row.change, current)
{
style = style.bg(bg);
}
let mut spans = Vec::with_capacity(4);
if budget.gutter {
spans.push(gutter_symbol(row, pane, theme));
spans.push(Span::raw(" "));
}
let (no, text) = match cell {
Cell::Line { no, text, .. } => (*no, text),
Cell::Empty => return Line::from(spans).style(style),
Cell::Placeholder => {
spans.push(Span::styled(
tr("ui.pending"),
Style::new().fg(theme.placeholder_fg),
));
return Line::from(spans).style(style);
}
};
if budget.no_width > 0 {
let no_style = if current {
Style::new().fg(theme.fg_bright)
} else {
Style::new().fg(theme.fg_dim)
};
spans.push(Span::styled(
format!("{no:>w$} ", w = budget.no_width),
no_style,
));
}
if emphasis.is_empty() && fg_spans.is_empty() {
spans.push(Span::raw(text.clone()));
} else {
spans.extend(compose_spans(
text,
fg_spans,
emphasis,
theme.emph_bg(row.change, current),
));
}
Line::from(spans).style(style)
}
fn pane_fg(pane: Option<&PaneSyntax>, no: usize) -> &[(Color, Range<usize>)] {
pane.and_then(|p| p.lines.get(no.wrapping_sub(1)))
.map_or(&[], Vec::as_slice)
}
pub(crate) fn draw_columns(
frame: &mut Frame,
area: Rect,
merge: &mut FileMerge,
theme: &Theme,
highlight: &FileHighlight,
(rows, chunk_starts, max_no): (&[Row], &[usize], usize),
scroll_request: &mut isize,
) {
let height = (area.height as usize).saturating_sub(2);
let max_scroll = rows.len().saturating_sub(height);
if *scroll_request != 0 {
let step = (height / 2).max(1) as isize;
merge.scroll = merge
.scroll
.saturating_add_signed(*scroll_request * step)
.min(max_scroll);
merge.follow = false;
*scroll_request = 0;
}
let target = chunk_starts.get(merge.cursor).copied().unwrap_or(0);
let mut scroll = merge.scroll.min(max_scroll);
if merge.follow && height > 0 && (target < scroll || target >= scroll + height) {
scroll = target.saturating_sub(height / 3).min(max_scroll);
}
merge.scroll = scroll;
let visible = &rows[scroll.min(rows.len())..rows.len().min(scroll + height)];
let cols = split_columns(area);
let panes = [
(Pane::Local, cols[0]),
(Pane::Result, cols[1]),
(Pane::Remote, cols[2]),
];
for (pane, col) in panes {
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(theme.border))
.title(Span::styled(
pane_title(pane, merge),
Style::new()
.fg(theme.fg_bright)
.add_modifier(Modifier::BOLD),
));
let inner = block.inner(col);
let budget = cell_budget(inner.width, max_no);
let lines: Vec<Line<'static>> = visible
.iter()
.map(|row| {
let cell = match pane {
Pane::Local => &row.ours,
Pane::Result => &row.result,
Pane::Remote => &row.theirs,
};
let emphasis: &[Range<usize>] = match (row.resolved, pane, cell) {
(false, Pane::Local, Cell::Line { offset, .. }) => highlight
.emphasis
.get(row.chunk)
.and_then(|e| e.ours.get(*offset))
.map_or(&[], Vec::as_slice),
(false, Pane::Remote, Cell::Line { offset, .. }) => highlight
.emphasis
.get(row.chunk)
.and_then(|e| e.theirs.get(*offset))
.map_or(&[], Vec::as_slice),
_ => &[],
};
let fg_spans: &[(Color, Range<usize>)] = match (pane, cell) {
(Pane::Local, Cell::Line { no, .. }) => pane_fg(highlight.ours.as_ref(), *no),
(Pane::Result, Cell::Line { offset, .. }) => highlight
.result
.as_ref()
.map_or(&[], |r| r.spans(row.chunk, *offset)),
(Pane::Remote, Cell::Line { no, .. }) => {
pane_fg(highlight.theirs.as_ref(), *no)
}
_ => &[],
};
let current = row.chunk == merge.cursor && row.change != ChangeType::None;
match row.fold {
Some(n) => fold_line(n, pane, inner.width, theme),
None => cell_line(
row,
current,
cell,
pane,
&budget,
theme,
(emphasis, fg_spans),
),
}
})
.collect();
frame.render_widget(block, col);
frame.render_widget(Paragraph::new(lines), inner);
}
draw_scrollbar(frame, cols[2], max_scroll, scroll, theme);
}
fn draw_scrollbar(frame: &mut Frame, col: Rect, max_scroll: usize, scroll: usize, theme: &Theme) {
if max_scroll == 0 || col.height <= 2 || col.width < 2 {
return;
}
let track = Rect {
x: col.x + col.width - 1,
y: col.y + 1,
width: 1,
height: col.height - 2,
};
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_symbol(Some("│"))
.track_style(Style::new().fg(theme.border))
.thumb_symbol("┃")
.thumb_style(Style::new().fg(theme.scrollbar_thumb));
let mut state = ScrollbarState::new(max_scroll).position(scroll);
frame.render_stateful_widget(scrollbar, track, &mut state);
}
fn split_columns(area: Rect) -> [Rect; 3] {
Layout::horizontal([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.areas(area)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compose_spans_merges_overlapping_layers() {
let fg = [(Color::Red, 0..4)];
let emphasis = std::slice::from_ref(&(2..6));
let spans = compose_spans("abcdef", &fg, emphasis, Color::Blue);
assert_eq!(spans.len(), 3);
assert_eq!(spans[0].content, "ab");
assert_eq!(
(spans[0].style.fg, spans[0].style.bg),
(Some(Color::Red), None)
);
assert_eq!(spans[1].content, "cd");
assert_eq!(
(spans[1].style.fg, spans[1].style.bg),
(Some(Color::Red), Some(Color::Blue))
);
assert_eq!(spans[2].content, "ef");
assert_eq!(
(spans[2].style.fg, spans[2].style.bg),
(None, Some(Color::Blue))
);
}
#[test]
fn compose_spans_clamps_out_of_range() {
let spans = compose_spans("ab", &[], std::slice::from_ref(&(1..99)), Color::Blue);
assert_eq!(spans.len(), 2);
assert_eq!(spans[1].content, "b");
assert_eq!(spans[1].style.bg, Some(Color::Blue));
}
}