use ratatui::{
Frame,
layout::{Constraint, Flex, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::app::App;
use crate::forge::traits::ForgeKind;
use crate::model::LineRange;
use crate::theme::Theme;
use crate::ui::styles;
const BORDER_PREFIX: &str = " │ ";
const BORDER_PREFIX_WIDTH: usize = 7;
pub(crate) fn wrap_segments(text: &str, content_area: usize) -> Vec<&str> {
if content_area == 0 || text.width() <= content_area {
return vec![text];
}
let mut segments = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
let mut take_bytes = 0usize;
let mut taken_width = 0usize;
for c in remaining.chars() {
let cw = UnicodeWidthChar::width(c).unwrap_or(0);
if taken_width + cw > content_area {
break;
}
taken_width += cw;
take_bytes += c.len_utf8();
}
if take_bytes == 0 {
take_bytes = remaining.chars().next().map_or(0, |c| c.len_utf8());
}
let (seg, rest) = remaining.split_at(take_bytes);
segments.push(seg);
remaining = rest;
}
segments
}
fn highlighted_window_spans(
runs: Option<&[(Style, String)]>,
line_text: &str,
start: usize,
end: usize,
) -> Vec<Span<'static>> {
if start >= end {
return Vec::new();
}
let Some(runs) = runs else {
return vec![Span::raw(line_text[start..end].to_string())];
};
let mut out = Vec::new();
let mut run_start = 0usize;
for (style, text) in runs {
let run_end = run_start + text.len();
let lo = start.max(run_start);
let hi = end.min(run_end);
if lo < hi {
out.push(Span::styled(
text[lo - run_start..hi - run_start].to_string(),
*style,
));
}
run_start = run_end;
if run_start >= end {
break;
}
}
out
}
#[derive(Debug, Clone)]
pub struct CommentCursorInfo {
pub line_offset: usize,
pub column: u16,
}
#[derive(Debug, Clone)]
pub struct CommentTypePresentation {
pub label: String,
pub color: Color,
}
#[allow(clippy::too_many_arguments)]
pub fn format_comment_input_lines(
theme: &Theme,
comment_type: CommentTypePresentation,
buffer: &str,
cursor_pos: usize,
line_range: Option<LineRange>,
is_editing: bool,
width: usize,
vim_mode: Option<(&str, bool)>,
) -> (Vec<Line<'static>>, CommentCursorInfo) {
let type_style = styles::comment_type_style(theme, comment_type.color);
let border_style = styles::comment_border_style(theme, comment_type.color);
let cursor_style = Style::default()
.fg(theme.cursor_color)
.add_modifier(Modifier::UNDERLINED);
let action = if is_editing { "Edit" } else { "Add" };
let line_info = match line_range {
Some(range) if range.is_single() => format!("L{} ", range.start),
Some(range) => format!("L{}-L{} ", range.start, range.end),
None => String::new(),
};
let newline_hint = "Shift-Enter";
let content_area = width.saturating_sub(BORDER_PREFIX_WIDTH + 2);
let mut result = Vec::new();
let mut cursor_line_offset: usize = 1;
let mut cursor_column: u16 = BORDER_PREFIX_WIDTH as u16;
let top_corner = if line_range.is_some() { '├' } else { '╭' };
let top_prefix = format!(" {top_corner}── ");
let hint = match vim_mode {
Some(_) => "(i:insert Alt-Enter:save Esc:normal :w save :q discard)".to_string(),
None => format!("(Tab/S-Tab:type Enter:save {newline_hint}:newline Esc:cancel)"),
};
let mut header_spans = vec![
Span::styled(top_prefix, border_style),
Span::styled(format!("{action} "), styles::dim_style(theme)),
];
if !comment_type.label.is_empty() {
header_spans.push(Span::styled(
format!("[{}] ", comment_type.label),
type_style,
));
}
if let Some((mode, warn)) = vim_mode {
let mode_style = if warn {
Style::default()
.fg(theme.comment_issue)
.add_modifier(Modifier::BOLD)
} else {
type_style
};
header_spans.push(Span::styled(format!("[{mode}] "), mode_style));
}
header_spans.push(Span::styled(line_info, styles::dim_style(theme)));
header_spans.push(Span::styled(hint, styles::dim_style(theme)));
result.push(Line::from(header_spans));
if buffer.is_empty() {
result.push(Line::from(vec![
Span::styled(BORDER_PREFIX, border_style),
Span::styled(" ", cursor_style),
Span::styled("Type your comment...", styles::dim_style(theme)),
]));
} else {
let buffer_lines: Vec<&str> = buffer.split('\n').collect();
let owned_lines: Vec<String> = buffer_lines.iter().map(|s| s.to_string()).collect();
let highlighted = theme
.syntax_highlighter()
.highlight_markdown_lines(&owned_lines);
let mut byte_offset = 0;
let mut total_visual_lines: usize = 0;
for (line_idx, text) in buffer_lines.iter().enumerate() {
let line_start = byte_offset;
let line_end = byte_offset + text.len();
let is_last_logical = line_idx + 1 == buffer_lines.len();
let cursor_on_this_line = cursor_pos >= line_start
&& (cursor_pos <= line_end || (is_last_logical && cursor_pos == buffer.len()));
let segments = wrap_segments(text, content_area);
let mut seg_byte_start = 0usize;
for (seg_idx, seg) in segments.iter().enumerate() {
let seg_start = line_start + seg_byte_start;
let seg_end = seg_start + seg.len();
let is_last_seg = seg_idx + 1 == segments.len();
let cursor_in_seg = cursor_on_this_line
&& cursor_pos >= seg_start
&& (cursor_pos < seg_end || is_last_seg);
let mut line_spans = vec![Span::styled(BORDER_PREFIX, border_style)];
let line_runs = highlighted.get(line_idx).and_then(|o| o.as_deref());
let seg_end_in_line = seg_byte_start + seg.len();
if cursor_in_seg {
let cursor_pos_in_seg = (cursor_pos - seg_start).min(seg.len());
let (before, after) = seg.split_at(cursor_pos_in_seg);
let cursor_in_line = seg_byte_start + cursor_pos_in_seg;
cursor_line_offset = 1 + total_visual_lines;
cursor_column = BORDER_PREFIX_WIDTH as u16 + before.width() as u16;
line_spans.extend(highlighted_window_spans(
line_runs,
text,
seg_byte_start,
cursor_in_line,
));
if let Some(cursor_char) = after.chars().next() {
line_spans.push(Span::styled(cursor_char.to_string(), cursor_style));
line_spans.extend(highlighted_window_spans(
line_runs,
text,
cursor_in_line + cursor_char.len_utf8(),
seg_end_in_line,
));
}
} else {
line_spans.extend(highlighted_window_spans(
line_runs,
text,
seg_byte_start,
seg_end_in_line,
));
}
result.push(Line::from(line_spans));
total_visual_lines += 1;
seg_byte_start += seg.len();
}
byte_offset = line_end + 1;
}
}
result.push(Line::from(vec![Span::styled(
" ╰".to_string() + &"─".repeat(width.saturating_sub(5)),
border_style,
)]));
let cursor_info = CommentCursorInfo {
line_offset: cursor_line_offset,
column: cursor_column,
};
(result, cursor_info)
}
pub fn format_remote_thread_lines(
theme: &Theme,
thread: &crate::forge::remote_comments::RemoteReviewThread,
muted: bool,
forge_kind: Option<ForgeKind>,
) -> Vec<Line<'static>> {
let (badge_fg, border_fg, body_fg) = if muted {
(theme.fg_dim, theme.fg_dim, theme.fg_dim)
} else {
(
theme.diff_hunk_header,
theme.diff_hunk_header,
theme.fg_secondary,
)
};
let badge_style = Style::default().fg(badge_fg).add_modifier(Modifier::BOLD);
let reply_badge_style = Style::default().fg(badge_fg);
let border_style = Style::default().fg(border_fg);
let body_style = Style::default().fg(body_fg);
let line_info = match thread.line.map(LineRange::single) {
Some(range) if range.is_single() => format!("L{} ", range.start),
Some(range) => format!("L{}-L{} ", range.start, range.end),
None => String::new(),
};
let mut result = Vec::new();
let mut iter = thread.comments.iter().peekable();
let mut is_first = true;
while let Some(comment) = iter.next() {
let author = comment.author.as_deref().unwrap_or("unknown");
if is_first {
let mut badge_text = format!("[{} @{author}", forge_badge_label(forge_kind));
if thread.is_resolved {
badge_text.push_str(" resolved");
} else if thread.is_outdated {
badge_text.push_str(" outdated");
}
badge_text.push_str("] ");
result.push(Line::from(vec![
Span::styled(" ├── ".to_string(), border_style),
Span::styled(badge_text, badge_style),
Span::styled(line_info.clone(), styles::dim_style(theme)),
Span::styled("─".repeat(20), border_style),
]));
} else {
result.push(Line::from(vec![
Span::styled(" ├── ".to_string(), border_style),
Span::styled(format!("↳ @{author} "), reply_badge_style),
Span::styled("─".repeat(28), border_style),
]));
}
for line in comment.body.split('\n') {
result.push(Line::from(vec![
Span::styled(" │ ".to_string(), border_style),
Span::styled(line.to_string(), body_style),
]));
}
is_first = false;
let _ = iter.peek();
}
result.push(Line::from(vec![Span::styled(
" ╰".to_string() + &"─".repeat(39),
border_style,
)]));
result
}
pub fn format_remote_review_summary_lines(
theme: &Theme,
summary: &crate::forge::remote_comments::RemoteReviewSummary,
forge_kind: Option<ForgeKind>,
) -> Vec<Line<'static>> {
let badge_fg = theme.diff_hunk_header;
let border_fg = theme.diff_hunk_header;
let body_fg = theme.fg_secondary;
let badge_style = Style::default().fg(badge_fg).add_modifier(Modifier::BOLD);
let border_style = Style::default().fg(border_fg);
let body_style = Style::default().fg(body_fg);
let author = summary.author.as_deref().unwrap_or("unknown");
let mut badge_text = format!("[{} @{author}", forge_badge_label(forge_kind));
if let Some(state_label) = summary.state.badge_label() {
badge_text.push(' ');
badge_text.push_str(state_label);
}
badge_text.push_str("] ");
let mut result = Vec::new();
result.push(Line::from(vec![
Span::styled(" ╭── ".to_string(), border_style),
Span::styled(badge_text, badge_style),
Span::styled("─".repeat(28), border_style),
]));
for line in summary.body.split('\n') {
result.push(Line::from(vec![
Span::styled(" │ ".to_string(), border_style),
Span::styled(line.to_string(), body_style),
]));
}
result.push(Line::from(vec![Span::styled(
" ╰".to_string() + &"─".repeat(39),
border_style,
)]));
result
}
fn forge_badge_label(kind: Option<ForgeKind>) -> &'static str {
match kind {
Some(ForgeKind::GitHub) => "github",
Some(ForgeKind::GitLab) => "gitlab",
Some(ForgeKind::Bitbucket) => "bitbucket",
None => "forge",
}
}
fn markdown_body_lines(
theme: &Theme,
content: &str,
content_area: usize,
border_style: Style,
) -> Vec<Line<'static>> {
let lines: Vec<&str> = content.split('\n').collect();
let owned: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
let highlighted = theme.syntax_highlighter().highlight_markdown_lines(&owned);
let mut out = Vec::new();
for (idx, text) in lines.iter().enumerate() {
let runs = highlighted.get(idx).and_then(|o| o.as_deref());
let mut seg_start = 0usize;
for seg in wrap_segments(text, content_area) {
let seg_end = seg_start + seg.len();
let mut spans = vec![Span::styled(BORDER_PREFIX, border_style)];
spans.extend(highlighted_window_spans(runs, text, seg_start, seg_end));
out.push(Line::from(spans));
seg_start = seg_end;
}
}
out
}
pub fn format_comment_lines(
theme: &Theme,
comment_type: CommentTypePresentation,
content: &str,
line_range: Option<LineRange>,
width: usize,
author: Option<&str>,
) -> Vec<Line<'static>> {
let type_style = styles::comment_type_style(theme, comment_type.color);
let border_style = match author {
Some(name) => Style::default()
.fg(styles::author_color_for(name))
.add_modifier(ratatui::style::Modifier::BOLD),
None => styles::comment_border_style(theme, comment_type.color),
};
let badge_text = match (author, comment_type.label.is_empty()) {
(Some(name), true) => format!("[@{name}] "),
(Some(name), false) => format!("[{} @{name}] ", comment_type.label),
(None, true) => String::new(),
(None, false) => format!("[{}] ", comment_type.label),
};
let badge_width = badge_text.width();
let line_info = match line_range {
Some(range) if range.is_single() => format!("L{} ", range.start),
Some(range) => format!("L{}-L{} ", range.start, range.end),
None => String::new(),
};
let content_area = width.saturating_sub(BORDER_PREFIX_WIDTH + 2);
let mut result = Vec::new();
let top_corner = if line_range.is_some() { '├' } else { '╭' };
let top_prefix = format!(" {top_corner}── ");
let top_fill = width.saturating_sub(8 + badge_width + line_info.width());
result.push(Line::from(vec![
Span::styled(top_prefix, border_style),
Span::styled(badge_text, type_style),
Span::styled(line_info, styles::dim_style(theme)),
Span::styled("─".repeat(top_fill), border_style),
]));
result.extend(markdown_body_lines(
theme,
content,
content_area,
border_style,
));
result.push(Line::from(vec![Span::styled(
" ╰".to_string() + &"─".repeat(width.saturating_sub(5)),
border_style,
)]));
result
}
pub fn render_confirm_dialog(frame: &mut Frame, app: &App, message: &str) {
let theme = &app.theme;
let area = centered_rect(50, 20, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(" Confirm ")
.borders(Borders::ALL)
.style(styles::popup_style(theme))
.border_style(styles::border_style(theme, true));
let inner = block.inner(area);
frame.render_widget(block, area);
let lines = vec![
Line::from(""),
Line::from(Span::raw(message)),
Line::from(""),
Line::from(vec![
Span::styled(" [Y]", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("es "),
Span::styled("[N]", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("o"),
]),
];
let paragraph = Paragraph::new(lines)
.style(styles::popup_style(theme))
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(paragraph, inner);
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::Center);
let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center);
let [area] = vertical.areas(area);
let [area] = horizontal.areas(area);
area
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::Theme;
use ratatui::style::Color;
fn test_theme() -> Theme {
Theme::default()
}
#[test]
fn wrap_segments_returns_single_segment_when_text_fits() {
let text = "hello";
let segments = wrap_segments(text, 80);
assert_eq!(segments, vec!["hello"]);
}
#[test]
fn wrap_segments_returns_single_segment_for_empty_text() {
let text = "";
let segments = wrap_segments(text, 80);
assert_eq!(segments, vec![""]);
}
#[test]
fn wrap_segments_returns_text_unchanged_when_content_area_is_zero() {
let text = "anything";
let segments = wrap_segments(text, 0);
assert_eq!(segments, vec!["anything"]);
}
#[test]
fn wrap_segments_splits_long_ascii_at_content_area() {
let text = "hello world";
let segments = wrap_segments(text, 5);
assert_eq!(segments, vec!["hello", " worl", "d"]);
}
#[test]
fn wrap_segments_respects_cjk_display_width() {
let text = "中文测试";
let segments = wrap_segments(text, 4);
assert_eq!(segments, vec!["中文", "测试"]);
}
#[test]
fn wrap_segments_handles_mixed_ascii_and_cjk() {
let text = "a中b文";
let segments = wrap_segments(text, 3);
assert_eq!(segments, vec!["a中", "b文"]);
}
#[test]
fn wrap_segments_emits_oversized_char_to_avoid_infinite_loop() {
let text = "中a";
let segments = wrap_segments(text, 1);
assert_eq!(segments, vec!["中", "a"]);
}
#[test]
fn wrap_segments_handles_exact_width_boundary() {
let text = "12345";
let segments = wrap_segments(text, 5);
assert_eq!(segments, vec!["12345"]);
}
#[test]
fn should_return_cursor_at_start_for_empty_buffer() {
let theme = test_theme();
let (lines, cursor_info) = format_comment_input_lines(
&theme,
CommentTypePresentation {
label: "NOTE".to_string(),
color: Color::Blue,
},
"",
0,
None,
false,
80,
None,
);
assert_eq!(lines.len(), 3); assert_eq!(cursor_info.line_offset, 1); assert_eq!(cursor_info.column, 7); }
#[test]
fn should_return_cursor_position_for_ascii_text() {
let theme = test_theme();
let buffer = "hello";
let cursor_pos = 3;
let (_, cursor_info) = format_comment_input_lines(
&theme,
CommentTypePresentation {
label: "NOTE".to_string(),
color: Color::Blue,
},
buffer,
cursor_pos,
None,
false,
80,
None,
);
assert_eq!(cursor_info.line_offset, 1); assert_eq!(cursor_info.column, 7 + 3); }
#[test]
fn should_return_cursor_position_for_multibyte_text() {
let theme = test_theme();
let buffer = "안녕"; let cursor_pos = 3;
let (_, cursor_info) = format_comment_input_lines(
&theme,
CommentTypePresentation {
label: "NOTE".to_string(),
color: Color::Blue,
},
buffer,
cursor_pos,
None,
false,
80,
None,
);
assert_eq!(cursor_info.line_offset, 1);
assert_eq!(cursor_info.column, 7 + 2);
}
#[test]
fn should_return_cursor_position_at_end_of_text() {
let theme = test_theme();
let buffer = "test";
let cursor_pos = 4;
let (_, cursor_info) = format_comment_input_lines(
&theme,
CommentTypePresentation {
label: "NOTE".to_string(),
color: Color::Blue,
},
buffer,
cursor_pos,
None,
false,
80,
None,
);
assert_eq!(cursor_info.line_offset, 1);
assert_eq!(cursor_info.column, 7 + 4); }
#[test]
fn should_return_cursor_position_on_second_line() {
let theme = test_theme();
let buffer = "line1\nline2";
let cursor_pos = 8;
let (lines, cursor_info) = format_comment_input_lines(
&theme,
CommentTypePresentation {
label: "NOTE".to_string(),
color: Color::Blue,
},
buffer,
cursor_pos,
None,
false,
80,
None,
);
assert_eq!(lines.len(), 4); assert_eq!(cursor_info.line_offset, 2); assert_eq!(cursor_info.column, 7 + 2); }
#[test]
fn should_return_cursor_position_for_mixed_content() {
let theme = test_theme();
let buffer = "a좋b"; let cursor_pos = 4;
let (_, cursor_info) = format_comment_input_lines(
&theme,
CommentTypePresentation {
label: "NOTE".to_string(),
color: Color::Blue,
},
buffer,
cursor_pos,
None,
false,
80,
None,
);
assert_eq!(cursor_info.line_offset, 1);
assert_eq!(cursor_info.column, 7 + 3);
}
fn reconstruct(lines: &[Line<'static>]) -> String {
lines[1..lines.len() - 1]
.iter()
.map(|line| {
line.spans
.iter()
.skip(1) .map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
fn render_md(buffer: &str, cursor: usize) -> Vec<Line<'static>> {
let theme = test_theme();
format_comment_input_lines(
&theme,
CommentTypePresentation {
label: "NOTE".to_string(),
color: Color::Blue,
},
buffer,
cursor,
None,
false,
80,
None,
)
.0
}
#[test]
fn markdown_render_preserves_all_text() {
let buffer = "# Title\n**bold** and `code`\n- item";
let lines = render_md(buffer, 11);
assert_eq!(reconstruct(&lines), buffer);
}
#[test]
fn markdown_render_preserves_multibyte_text() {
let buffer = "# 世界\n**bold** 좋아";
for cursor in [0, buffer.find('世').unwrap(), buffer.len()] {
let lines = render_md(buffer, cursor);
assert_eq!(reconstruct(&lines), buffer, "cursor={cursor}");
}
}
#[test]
fn displayed_comment_is_markdown_highlighted_and_preserves_text() {
let theme = test_theme();
let content = "# Heading\n**bold** and `code`\n- item";
let lines = format_comment_lines(
&theme,
CommentTypePresentation {
label: "NOTE".to_string(),
color: Color::Blue,
},
content,
None,
80,
None,
);
assert_eq!(reconstruct(&lines), content);
let code_line = &lines[2]; assert!(
code_line.spans.len() - 1 > 1,
"expected displayed markdown to be highlighted into multiple spans"
);
}
#[test]
fn markdown_highlighting_splits_line_into_runs() {
let buffer = "plain `code` plain";
let lines = render_md(buffer, buffer.len());
let content_spans = lines[1].spans.len() - 1; assert!(
content_spans > 1,
"expected markdown highlighting to split the line, got {content_spans} span(s)"
);
}
#[test]
fn remote_thread_badge_uses_gitlab_for_gitlab_comments() {
let thread = crate::forge::remote_comments::RemoteReviewThread {
id: "thread".to_string(),
path: "src/lib.rs".to_string(),
line: Some(1),
side: crate::forge::remote_comments::RemoteCommentSide::Right,
is_resolved: false,
is_outdated: false,
comments: vec![crate::forge::remote_comments::RemoteReviewComment {
id: "comment".to_string(),
author: Some("alice".to_string()),
body: "body".to_string(),
created_at: None,
in_reply_to: None,
url: String::new(),
}],
};
let lines =
format_remote_thread_lines(&test_theme(), &thread, false, Some(ForgeKind::GitLab));
let header = lines[0]
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(header.contains("[gitlab @alice]"));
assert!(!header.contains("[github @alice]"));
}
#[test]
fn remote_summary_badge_uses_github_for_github_comments() {
let summary = crate::forge::remote_comments::RemoteReviewSummary {
id: "summary".to_string(),
author: Some("alice".to_string()),
body: "body".to_string(),
state: crate::forge::remote_comments::RemoteReviewState::Commented,
created_at: None,
url: String::new(),
};
let lines =
format_remote_review_summary_lines(&test_theme(), &summary, Some(ForgeKind::GitHub));
let header = lines[0]
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(header.contains("[github @alice]"));
}
}