use ratatui::{
layout::{Constraint, Direction, Layout, Margin},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
Block, Borders, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, Wrap,
},
Frame,
};
use unicode_width::UnicodeWidthChar;
use super::common::render_rally_status_bar;
use crate::app::{App, CommentTab};
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
if max_width == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
for ch in text.chars() {
if ch == '\n' {
continue;
}
let char_width = ch.width().unwrap_or(1);
if current_width + char_width > max_width {
lines.push(current_line);
current_line = String::new();
current_width = 0;
}
current_line.push(ch);
current_width += char_width;
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
pub fn render(frame: &mut Frame, app: &mut App) {
if app.discussion_comment_detail_mode {
render_discussion_detail(frame, app);
return;
}
let has_rally = app.has_background_rally();
let constraints = if has_rally {
vec![
Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), Constraint::Length(3), ]
} else {
vec![
Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ]
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(frame.area());
render_tab_header(frame, app, chunks[0]);
match app.comment_tab {
CommentTab::Review => render_review_comments(frame, app, chunks[1]),
CommentTab::Discussion => render_discussion_comments(frame, app, chunks[1]),
}
if has_rally {
render_rally_status_bar(frame, chunks[2], app);
}
let footer_chunk_idx = if has_rally { 3 } else { 2 };
let footer_text = match app.comment_tab {
CommentTab::Review => "j/k/↑↓: move | Enter: jump to file | [/]: switch tab | q: back",
CommentTab::Discussion => "j/k/↑↓: move | Enter: view detail | [/]: switch tab | q: back",
};
let footer = Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL));
frame.render_widget(footer, chunks[footer_chunk_idx]);
}
#[allow(clippy::too_many_arguments)]
fn render_comment_list_generic<T, F>(
frame: &mut Frame,
area: ratatui::layout::Rect,
comments: Option<&[T]>,
loading: bool,
selected_index: usize,
scroll_offset: &mut usize,
label: &str,
format_item: F,
) where
F: Fn(&T, usize, bool, usize) -> ListItem<'static>,
{
if loading && comments.is_none() {
let loading_msg = Paragraph::new(format!("Loading {}...", label))
.style(Style::default().fg(Color::Yellow))
.block(Block::default().borders(Borders::ALL));
frame.render_widget(loading_msg, area);
return;
}
let Some(items_data) = comments else {
let empty = Paragraph::new(format!("No {}", label))
.style(Style::default().fg(Color::DarkGray))
.block(Block::default().borders(Borders::ALL));
frame.render_widget(empty, area);
return;
};
if items_data.is_empty() {
let empty = Paragraph::new(format!("No {} found", label))
.style(Style::default().fg(Color::DarkGray))
.block(Block::default().borders(Borders::ALL));
frame.render_widget(empty, area);
return;
}
let available_width = area.width.saturating_sub(4) as usize;
let body_width = available_width.saturating_sub(4);
let items: Vec<ListItem> = items_data
.iter()
.enumerate()
.map(|(i, item)| {
let is_selected = i == selected_index;
format_item(item, i, is_selected, body_width)
})
.collect();
let mut list_state = ListState::default()
.with_offset(*scroll_offset)
.with_selected(Some(selected_index));
let block = Block::default().borders(Borders::ALL);
let total_items = items_data.len();
let list = List::new(items).block(block).highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, area, &mut list_state);
*scroll_offset = list_state.offset();
if total_items > 1 {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("▲"))
.end_symbol(Some("▼"));
let mut scrollbar_state =
ScrollbarState::new(total_items.saturating_sub(1)).position(selected_index);
frame.render_stateful_widget(
scrollbar,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
}
}
fn render_tab_header(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let review_count = app.review_comments.as_ref().map(|c| c.len()).unwrap_or(0);
let discussion_count = app
.discussion_comments
.as_ref()
.map(|c| c.len())
.unwrap_or(0);
let review_style = if app.comment_tab == CommentTab::Review {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let discussion_style = if app.comment_tab == CommentTab::Discussion {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let loading_indicator = |loading: bool| -> String {
if loading {
format!(" {}", app.spinner_char())
} else {
String::new()
}
};
let header_line = Line::from(vec![
Span::raw(" "),
Span::styled(
format!(
"[Review Comments ({})]{}",
review_count,
loading_indicator(app.comments_loading)
),
review_style,
),
Span::raw(" "),
Span::styled(
format!(
"[Discussion ({})]{}",
discussion_count,
loading_indicator(app.discussion_comments_loading)
),
discussion_style,
),
]);
let header =
Paragraph::new(header_line).block(Block::default().borders(Borders::ALL).title("octorus"));
frame.render_widget(header, area);
}
fn render_review_comments(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
use crate::github::comment::ReviewComment;
render_comment_list_generic(
frame,
area,
app.review_comments.as_deref(),
app.comments_loading,
app.selected_comment,
&mut app.comment_list_scroll_offset,
"review comments",
|comment: &ReviewComment, _i: usize, is_selected: bool, body_width: usize| {
let prefix = if is_selected { "> " } else { " " };
let line_info = comment.line.map(|l| format!(":{}", l)).unwrap_or_default();
let header_line = Line::from(vec![
Span::raw(prefix),
Span::styled(
format!("@{}", comment.user.login),
Style::default().fg(Color::Cyan),
),
Span::raw(" on "),
Span::styled(
format!("{}{}", comment.path, line_info),
Style::default().fg(Color::Green),
),
]);
let body_text: String = comment.body.lines().collect::<Vec<_>>().join(" ");
let wrapped_lines = wrap_text(&body_text, body_width);
let mut lines = vec![header_line];
for wrapped_line in wrapped_lines {
lines.push(Line::from(vec![Span::raw(" "), Span::raw(wrapped_line)]));
}
lines.push(Line::from(""));
ListItem::new(lines)
},
);
}
fn render_discussion_comments(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
use crate::github::comment::DiscussionComment;
render_comment_list_generic(
frame,
area,
app.discussion_comments.as_deref(),
app.discussion_comments_loading,
app.selected_discussion_comment,
&mut app.discussion_comment_list_scroll_offset,
"discussion comments",
|comment: &DiscussionComment, _i: usize, is_selected: bool, body_width: usize| {
let prefix = if is_selected { "> " } else { " " };
let date = comment
.created_at
.split('T')
.next()
.unwrap_or(&comment.created_at);
let header_line = Line::from(vec![
Span::raw(prefix),
Span::styled(
format!("@{}", comment.user.login),
Style::default().fg(Color::Cyan),
),
Span::raw(" "),
Span::styled(date.to_string(), Style::default().fg(Color::DarkGray)),
]);
let body_text: String = comment.body.lines().collect::<Vec<_>>().join(" ");
let truncated = if body_text.len() > body_width * 2 {
format!("{}...", &body_text[..body_width * 2])
} else {
body_text
};
let wrapped_lines = wrap_text(&truncated, body_width);
let mut lines = vec![header_line];
for wrapped_line in wrapped_lines {
lines.push(Line::from(vec![Span::raw(" "), Span::raw(wrapped_line)]));
}
lines.push(Line::from(""));
ListItem::new(lines)
},
);
}
fn render_discussion_detail(frame: &mut Frame, app: &App) {
let Some(ref comments) = app.discussion_comments else {
return;
};
let Some(comment) = comments.get(app.selected_discussion_comment) else {
return;
};
let has_rally = app.has_background_rally();
let constraints = if has_rally {
vec![
Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), Constraint::Length(3), ]
} else {
vec![
Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ]
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(frame.area());
let date = comment
.created_at
.split('T')
.next()
.unwrap_or(&comment.created_at);
let header = Paragraph::new(Line::from(vec![
Span::styled(
format!("@{}", comment.user.login),
Style::default().fg(Color::Cyan),
),
Span::raw(" "),
Span::styled(date.to_string(), Style::default().fg(Color::DarkGray)),
]))
.block(
Block::default()
.borders(Borders::ALL)
.title("Comment Detail"),
);
frame.render_widget(header, chunks[0]);
let content_height = chunks[1].height.saturating_sub(2) as usize;
let body_lines: Vec<Line> = comment
.body
.lines()
.skip(app.discussion_comment_detail_scroll)
.take(content_height)
.map(|line| Line::from(line.to_string()))
.collect();
let total_lines = comment.body.lines().count();
let scroll_info = if total_lines > content_height {
format!(
" ({}/{})",
app.discussion_comment_detail_scroll + 1,
total_lines.saturating_sub(content_height) + 1
)
} else {
String::new()
};
let content = Paragraph::new(body_lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!("Content{}", scroll_info)),
)
.wrap(Wrap { trim: false });
frame.render_widget(content, chunks[1]);
if has_rally {
render_rally_status_bar(frame, chunks[2], app);
}
let footer_chunk_idx = if has_rally { 3 } else { 2 };
let footer = Paragraph::new("j/k/↑↓: scroll | Ctrl+d/u: page | Enter/Esc: back to list")
.block(Block::default().borders(Borders::ALL));
frame.render_widget(footer, chunks[footer_chunk_idx]);
}