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::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 push_cursor_spans(
spans: &mut Vec<Span<'static>>,
before: &str,
after: &str,
cursor_style: Style,
) {
spans.push(Span::raw(before.to_string()));
let mut chars = after.chars();
if let Some(cursor_char) = chars.next() {
spans.push(Span::styled(cursor_char.to_string(), cursor_style));
spans.push(Span::raw(chars.as_str().to_string()));
}
}
#[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,
supports_keyboard_enhancement: bool,
width: usize,
) -> (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 = if supports_keyboard_enhancement {
"Shift-Enter"
} else {
"Ctrl-J"
};
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}── ");
result.push(Line::from(vec![
Span::styled(top_prefix, border_style),
Span::styled(format!("{} ", action), styles::dim_style(theme)),
Span::styled(format!("[{}] ", comment_type.label), type_style),
Span::styled(line_info, styles::dim_style(theme)),
Span::styled(
format!(
"(Tab/S-Tab:type Enter:save {}:newline Esc:cancel)",
newline_hint
),
styles::dim_style(theme),
),
]));
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 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)];
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);
cursor_line_offset = 1 + total_visual_lines;
cursor_column = BORDER_PREFIX_WIDTH as u16 + before.width() as u16;
push_cursor_spans(&mut line_spans, before, after, cursor_style);
} else {
line_spans.push(Span::raw(seg.to_string()));
}
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,
) -> 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!("[github @{author}");
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_comment_lines(
theme: &Theme,
comment_type: CommentTypePresentation,
content: &str,
line_range: Option<LineRange>,
width: usize,
) -> Vec<Line<'static>> {
let type_style = styles::comment_type_style(theme, comment_type.color);
let border_style = styles::comment_border_style(theme, comment_type.color);
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 content_lines: Vec<&str> = content.split('\n').collect();
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 + comment_type.label.len() + 3 + line_info.width());
result.push(Line::from(vec![
Span::styled(top_prefix, border_style),
Span::styled(format!("[{}] ", comment_type.label), type_style),
Span::styled(line_info, styles::dim_style(theme)),
Span::styled("─".repeat(top_fill), border_style),
]));
for line in &content_lines {
for seg in wrap_segments(line, content_area) {
result.push(Line::from(vec![
Span::styled(BORDER_PREFIX, border_style),
Span::raw(seg.to_string()),
]));
}
}
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,
false,
80,
);
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,
false,
80,
);
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,
false,
80,
);
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,
false,
80,
);
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,
false,
80,
);
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,
false,
80,
);
assert_eq!(cursor_info.line_offset, 1);
assert_eq!(cursor_info.column, 7 + 3);
}
}