use ratatui::{
Frame,
layout::{Constraint, Flex, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use unicode_width::UnicodeWidthStr;
use crate::app::App;
use crate::model::LineRange;
use crate::theme::Theme;
use crate::ui::styles;
#[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,
}
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,
) -> (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 mut result = Vec::new();
let border_prefix = " │ ";
let border_width = border_prefix.width() as u16;
let mut cursor_line_offset: usize = 1; let mut cursor_column: u16 = border_width;
result.push(Line::from(vec![
Span::styled(" ╭─ ", 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 char_offset = 0;
for (line_idx, text) in buffer_lines.iter().enumerate() {
let line_start = char_offset;
let line_end = char_offset + text.len();
let cursor_on_this_line = cursor_pos >= line_start
&& (cursor_pos <= line_end
|| (line_idx == buffer_lines.len() - 1 && cursor_pos == buffer.len()));
let mut line_spans = vec![Span::styled(border_prefix, border_style)];
if cursor_on_this_line {
let cursor_pos_in_line = cursor_pos - line_start;
let cursor_pos_in_line = cursor_pos_in_line.min(text.len());
let (before_cursor, after_cursor) = text.split_at(cursor_pos_in_line);
cursor_line_offset = 1 + line_idx;
cursor_column = border_width + before_cursor.width() as u16;
if after_cursor.is_empty() {
line_spans.push(Span::raw(before_cursor.to_string()));
line_spans.push(Span::styled(" ", cursor_style));
} else {
let mut chars = after_cursor.chars();
let cursor_char = chars.next().unwrap();
let remaining = chars.as_str();
line_spans.push(Span::raw(before_cursor.to_string()));
line_spans.push(Span::styled(cursor_char.to_string(), cursor_style));
line_spans.push(Span::raw(remaining.to_string()));
}
} else {
line_spans.push(Span::raw(text.to_string()));
}
result.push(Line::from(line_spans));
char_offset = line_end + 1;
}
}
result.push(Line::from(vec![Span::styled(
" ╰".to_string() + &"─".repeat(38),
border_style,
)]));
let cursor_info = CommentCursorInfo {
line_offset: cursor_line_offset,
column: cursor_column,
};
(result, cursor_info)
}
pub fn format_comment_lines(
theme: &Theme,
comment_type: CommentTypePresentation,
content: &str,
line_range: Option<LineRange>,
) -> 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_lines: Vec<&str> = content.split('\n').collect();
let mut result = Vec::new();
result.push(Line::from(vec![
Span::styled(" ╭─ ", border_style),
Span::styled(format!("[{}] ", comment_type.label), type_style),
Span::styled(line_info, styles::dim_style(theme)),
Span::styled("─".repeat(30), border_style),
]));
for line in &content_lines {
result.push(Line::from(vec![
Span::styled(" │ ", border_style),
Span::raw(line.to_string()),
]));
}
result.push(Line::from(vec![Span::styled(
" ╰".to_string() + &"─".repeat(38),
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 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,
);
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,
);
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,
);
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,
);
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,
);
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,
);
assert_eq!(cursor_info.line_offset, 1);
assert_eq!(cursor_info.column, 7 + 3);
}
}