use ratatui::{
Frame,
layout::Position,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use unicode_width::UnicodeWidthStr;
use crate::{diff::LineKind, state::App, theme::Theme, ui::centered_rect};
const CONTEXT_MAX_LINES: usize = 5;
pub fn render(f: &mut Frame, app: &App, theme: &Theme) {
let area = centered_rect(70, 60, f.area());
f.render_widget(Clear, area);
let title = if app.editing_comment.is_some() {
" Edit Comment "
} else {
" New Comment "
};
let bg = theme.bg;
let block = Block::default()
.borders(Borders::ALL)
.title(Span::styled(
title,
Style::default()
.fg(theme.bg)
.bg(theme.shuire)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(theme.shuire).bg(bg))
.style(Style::default().bg(bg));
let inner = block.inner(area);
f.render_widget(block, area);
if inner.width == 0 || inner.height == 0 {
return;
}
let body_style = Style::default().fg(theme.context_fg).bg(bg);
let dim_style = Style::default().fg(theme.dim_fg).bg(bg);
let caret_style = Style::default().fg(theme.caret_fg).bg(theme.caret_bg);
let mut rendered: Vec<Line> = Vec::new();
let loc_label = location_label(app);
rendered.push(Line::from(Span::styled(loc_label, dim_style)));
for line in context_lines(app, theme, inner.width as usize) {
rendered.push(line);
}
rendered.push(Line::from(Span::styled(
"─".repeat(inner.width as usize),
Style::default().fg(theme.shuire_dim).bg(bg),
)));
let body_top_row = rendered.len() as u16;
let (cursor_col, cursor_row) = push_input_body(
&mut rendered,
&app.input,
app.input_cursor,
body_style,
caret_style,
);
let hint = " [Enter] save [S-Enter/C-Enter/C-j] newline [Esc] cancel ";
let hint_row = inner.height.saturating_sub(1) as usize;
while rendered.len() < hint_row {
rendered.push(Line::from(Span::styled("", body_style)));
}
if (rendered.len() as u16) < inner.height {
rendered.push(Line::from(Span::styled(hint, dim_style)));
}
f.render_widget(
Paragraph::new(rendered).style(Style::default().bg(bg)),
inner,
);
let abs_row = inner.y + body_top_row + cursor_row;
let abs_col = inner.x + cursor_col;
if abs_row < inner.y + inner.height && abs_col < inner.x + inner.width {
f.set_cursor_position(Position::new(abs_col, abs_row));
}
}
fn location_label(app: &App) -> String {
let Some(file) = app.current() else {
return String::new();
};
let line = file.lines.get(app.cursor_line);
let lineno = line.and_then(|l| l.new_lineno.or(l.old_lineno));
match lineno {
Some(n) => format!(" {}:{} ", file.path, n),
None => format!(" {} ", file.path),
}
}
fn context_lines<'a>(app: &App, theme: &Theme, width: usize) -> Vec<Line<'a>> {
let Some(file) = app.current() else {
return Vec::new();
};
let (lo, hi) = match (app.visual_start, app.comment_line_end) {
(Some(start), _) => {
let end = app.cursor_line;
(start.min(end), start.max(end))
}
(None, Some(end)) => (app.cursor_line.min(end), app.cursor_line.max(end)),
(None, None) => (app.cursor_line, app.cursor_line),
};
let targets: Vec<&crate::diff::DiffLine> = file
.lines
.iter()
.enumerate()
.filter(|(i, l)| {
*i >= lo
&& *i <= hi
&& matches!(
l.kind,
LineKind::Added | LineKind::Removed | LineKind::Context,
)
})
.map(|(_, l)| l)
.collect();
if targets.is_empty() {
return Vec::new();
}
let (shown, truncated) = if targets.len() > CONTEXT_MAX_LINES {
(
&targets[..CONTEXT_MAX_LINES],
Some(targets.len() - CONTEXT_MAX_LINES),
)
} else {
(&targets[..], None)
};
let mut out: Vec<Line<'a>> = Vec::new();
for l in shown {
let (marker, fg) = match l.kind {
LineKind::Added => ("+ ", theme.added_fg),
LineKind::Removed => ("- ", theme.removed_fg),
_ => (" ", theme.context_fg),
};
let text = truncate_to_width(&l.text, width.saturating_sub(marker.width()));
out.push(Line::from(vec![
Span::styled(marker.to_string(), Style::default().fg(fg).bg(theme.bg)),
Span::styled(text, Style::default().fg(fg).bg(theme.bg)),
]));
}
if let Some(extra) = truncated {
out.push(Line::from(Span::styled(
format!(" … +{} more lines", extra),
Style::default().fg(theme.dim_fg).bg(theme.bg),
)));
}
out
}
fn truncate_to_width(text: &str, max: usize) -> String {
if text.width() <= max {
return text.to_string();
}
let mut out = String::new();
let mut used = 0;
for ch in text.chars() {
let w = ch.to_string().width();
if used + w + 1 > max {
break;
}
out.push(ch);
used += w;
}
out.push('…');
out
}
fn push_input_body<'a>(
rendered: &mut Vec<Line<'a>>,
input: &'a str,
cursor: usize,
body_style: Style,
caret_style: Style,
) -> (u16, u16) {
let mut cursor_col = 0u16;
let mut cursor_row = 0u16;
let mut byte_offset: usize = 0;
for (i, line_text) in input.split('\n').enumerate() {
let line_start = byte_offset;
let line_end = line_start + line_text.len();
let cursor_in_line = cursor >= line_start && cursor <= line_end;
if cursor_in_line {
let local = cursor - line_start;
let before = &line_text[..local];
let rest = &line_text[local..];
let (glyph, after): (std::borrow::Cow<'a, str>, &'a str) = match rest.chars().next() {
Some(ch) => (ch.to_string().into(), &rest[ch.len_utf8()..]),
None => (" ".into(), ""),
};
cursor_col = before.width() as u16;
cursor_row = i as u16;
rendered.push(Line::from(vec![
Span::styled(before, body_style),
Span::styled(glyph, caret_style),
Span::styled(after, body_style),
]));
} else {
rendered.push(Line::from(Span::styled(line_text, body_style)));
}
byte_offset = line_end + 1;
}
(cursor_col, cursor_row)
}