use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::Style,
text::{Line, Span},
widgets::Block,
};
use unicode_width::UnicodeWidthStr;
use crate::app::{App, DiffSource, DiffViewMode, ExpandDirection, GAP_EXPAND_BATCH, InputMode};
use crate::theme::Theme;
use crate::ui::{ai_summary_panel, comment_panel, help_popup, status_bar, styles};
pub(crate) fn expand_tabs(s: &str, tab_width: usize) -> String {
if !s.contains('\t') || tab_width == 0 {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut col: usize = 0;
for ch in s.chars() {
if ch == '\t' {
let spaces = tab_width - (col % tab_width);
for _ in 0..spaces {
out.push(' ');
}
col += spaces;
} else {
out.push(ch);
col += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
}
}
out
}
pub fn render(frame: &mut Frame, app: &mut App) {
frame.render_widget(
Block::default().style(styles::panel_style(&app.theme)),
frame.area(),
);
if app.nav.input_mode == InputMode::CommitSelect {
crate::ui::remote_panels::render_commit_select(frame, app);
return;
}
app.comment.cursor_screen_pos = None;
let tour_active = app.tour.plan.is_some();
let constraints = if tour_active {
vec![
Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ]
} else {
vec![
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ]
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(frame.area());
status_bar::render_header(frame, app, chunks[0]);
let (banner_idx, main_idx, status_idx) = if tour_active {
(Some(1usize), 2usize, 3usize)
} else {
(None, 1usize, 2usize)
};
if let Some(i) = banner_idx {
render_tour_banner(frame, app, chunks[i]);
}
render_main_content(frame, app, chunks[main_idx]);
status_bar::render_status_bar(frame, app, chunks[status_idx]);
if app.nav.input_mode == InputMode::Help {
help_popup::render_help(frame, app);
}
if app.ai.show_panel {
ai_summary_panel::render(frame, frame.area(), app);
}
if app.nav.input_mode == InputMode::ReactionPicker {
let app_ref: &App = app;
crate::ui::reaction_picker::render(frame, frame.area(), app_ref, &app_ref.theme);
}
if app.nav.input_mode == InputMode::MentalModelEdit {
let app_ref: &App = app;
crate::ui::mental_model_modal::render(frame, frame.area(), app_ref, &app_ref.theme);
}
if app.nav.input_mode == InputMode::Confirm {
let message = match app.pending_confirm {
Some(crate::app::ConfirmAction::Merge) => {
if let Some(r) = app.remote()
&& let Some(ref meta) = r.pr_metadata
{
format!("Merge PR #{}: {}? (Y/N)", r.pr_id.number, meta.title)
} else {
"Merge this PR? (Y/N)".to_string()
}
}
Some(crate::app::ConfirmAction::RestartReview) => {
format!(
"Restart review? This marks all {} reviewed file(s) unreviewed. (Y/N)",
app.reviewed_file_count()
)
}
_ => "Copy review to clipboard?".to_string(),
};
comment_panel::render_confirm_dialog(frame, app, &message);
}
if app.nav.input_mode == InputMode::ReviewSubmit {
comment_panel::render_review_submit_dialog(frame, app);
}
if app.agent_action.has_waiting_pending() {
comment_panel::render_forge_confirmation_modal(frame, app);
}
if app.nav.input_mode == InputMode::CommandPalette {
crate::ui::command_palette::render_command_palette(frame, app);
}
if app.nav.input_mode == InputMode::CommentTemplatePicker {
crate::ui::comment_template_picker::render_comment_template_picker(frame, app);
}
if app.nav.input_mode == InputMode::Comment {
let main_area = chunks[main_idx];
let (col, row) = app.comment.cursor_screen_pos.unwrap_or_else(|| {
if let Some(diff_area) = app.ui_layout.diff_area {
(diff_area.x + 1, diff_area.y + 1)
} else {
(main_area.x + 1, main_area.y + 1)
}
});
frame.set_cursor_position(ratatui::layout::Position { x: col, y: row });
}
}
fn render_tour_banner(frame: &mut Frame, app: &mut App, area: Rect) {
let Some(tour) = app.tour.plan.as_ref() else {
return;
};
let Some(stop) = tour.current() else {
return;
};
let total = tour.stops.len();
let idx = tour.index + 1;
let commit_count = stop.commit_ids.len();
let risk = stop.risk.as_u8();
let threshold = tour.threshold.as_u8();
let summary = stop.summary.trim().to_string();
let date_range = app.tour_date_range();
let theme = &app.theme;
let label_style = Style::default()
.fg(theme.message_info_fg)
.bg(theme.message_info_bg)
.add_modifier(ratatui::style::Modifier::BOLD);
let body_style = Style::default()
.fg(theme.message_info_fg)
.bg(theme.message_info_bg);
let mut spans: Vec<Span> = vec![
Span::styled(format!(" Tour {idx}/{total} "), label_style),
Span::styled("·", body_style),
];
if let Some(dr) = date_range {
spans.push(Span::styled(format!(" {dr} "), body_style));
spans.push(Span::styled("·", body_style));
}
let commits_str = if commit_count == 1 {
" 1 commit ".to_string()
} else {
format!(" {commit_count} commits ")
};
spans.push(Span::styled(commits_str, body_style));
spans.push(Span::styled("·", body_style));
spans.push(Span::styled(
format!(" risk={risk}/{threshold} "),
body_style,
));
spans.push(Span::styled("·", body_style));
spans.push(Span::styled(" ", body_style));
let prefix_width: usize = spans
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
let total_width = area.width as usize;
let summary_budget = total_width.saturating_sub(prefix_width).saturating_sub(1);
let summary_text =
if UnicodeWidthStr::width(summary.as_str()) > summary_budget && summary_budget > 3 {
let mut truncated = String::new();
let mut used = 0usize;
for ch in summary.chars() {
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if used + w > summary_budget.saturating_sub(1) {
break;
}
truncated.push(ch);
used += w;
}
truncated.push('…');
truncated
} else {
summary
};
spans.push(Span::styled(summary_text, body_style));
let line = Line::from(spans);
frame.render_widget(
ratatui::widgets::Paragraph::new(line).style(body_style),
area,
);
}
fn render_main_content(frame: &mut Frame, app: &mut App, area: Rect) {
let content_area = if app.has_inline_commit_selector() {
let selector_height = (app.inline_selector.commits.len() as u16 + 2).min(8); let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(selector_height), Constraint::Min(0)])
.split(area);
crate::ui::remote_panels::render_inline_commit_selector(frame, app, chunks[0]);
chunks[1]
} else {
area
};
let is_remote = matches!(app.diff_source, DiffSource::Remote { .. });
if app.ui_layout.show_file_list {
let file_pct = app.ui_layout.file_list_width_pct();
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(file_pct),
Constraint::Percentage(100 - file_pct),
])
.split(content_area);
app.ui_layout.file_list_area = Some(chunks[0]);
crate::ui::file_list::render_file_list(frame, app, chunks[0]);
if is_remote {
crate::ui::remote_panels::render_remote_right_panel(frame, app, chunks[1]);
} else {
app.ui_layout.diff_area = Some(chunks[1]);
render_diff_view(frame, app, chunks[1]);
}
} else {
app.ui_layout.file_list_area = None;
if is_remote {
crate::ui::remote_panels::render_remote_right_panel(frame, app, content_area);
} else {
app.ui_layout.diff_area = Some(content_area);
render_diff_view(frame, app, content_area);
}
}
}
pub(crate) fn render_diff_view(frame: &mut Frame, app: &mut App, area: Rect) {
if app.viewer.is_active() {
match app.refresh_viewer_content() {
Ok(()) => {
crate::ui::viewer_pane::render_viewer_pane(frame, app, area);
return;
}
Err(msg) => {
app.viewer.deactivate();
app.set_warning(msg);
}
}
}
match app.nav.diff_view_mode {
DiffViewMode::Unified => {
crate::ui::diff_unified::render_unified_diff(frame, app, area);
}
DiffViewMode::SideBySide => {
crate::ui::diff_side_by_side::render_side_by_side_diff(frame, app, area);
}
}
}
pub(crate) fn diff_stat_title(app: &App) -> Line<'static> {
let (additions, deletions) = if app.is_cursor_in_overview() || app.current_file_path().is_none()
{
let (_, a, d) = app.diff_stat();
(a, d)
} else {
app.diff_files[app.diff_state.current_file_idx].stat()
};
let theme = &app.theme;
Line::from(vec![
Span::styled(
format!(" +{additions}"),
Style::default().fg(theme.diff_add),
),
Span::raw(" "),
Span::styled(
format!("-{deletions} "),
Style::default().fg(theme.diff_del),
),
])
}
pub(crate) fn cursor_indicator(line_idx: usize, current_line_idx: usize) -> &'static str {
if line_idx == current_line_idx {
"▶"
} else {
" "
}
}
pub(crate) fn cursor_indicator_spaced(line_idx: usize, current_line_idx: usize) -> &'static str {
if line_idx == current_line_idx {
"▶ "
} else {
" "
}
}
pub(crate) fn render_expanded_context_line(
lines: &mut Vec<Line<'_>>,
line_idx: &mut usize,
current_line_idx: usize,
expanded_line: &travelagent_core::model::DiffLine,
theme: &Theme,
) {
let indicator = cursor_indicator(*line_idx, current_line_idx);
let line_num = expanded_line
.new_lineno
.map_or_else(|| " ".to_string(), |n| format!("{n:>4} "));
let line_spans = vec![
Span::styled(indicator, styles::current_line_indicator_style(theme)),
Span::styled(line_num, styles::gutter_expanded_style(theme)),
Span::styled(" ", styles::expanded_context_style(theme)),
Span::styled(
expanded_line.content.clone(),
styles::expanded_context_style(theme),
),
];
lines.push(Line::from(line_spans));
*line_idx += 1;
}
pub(crate) fn render_expander_line(
lines: &mut Vec<Line<'_>>,
line_idx: &mut usize,
current_line_idx: usize,
direction: ExpandDirection,
remaining: usize,
theme: &Theme,
) {
let arrow = match direction {
ExpandDirection::Down => "↓",
ExpandDirection::Up => "↑",
ExpandDirection::Both => "↕",
};
let count = remaining.min(GAP_EXPAND_BATCH);
let indicator = cursor_indicator_spaced(*line_idx, current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(theme)),
Span::styled(
format!(" ... {arrow} expand ({count} lines) ..."),
styles::dim_style(theme),
),
]));
*line_idx += 1;
}
pub(crate) fn render_hidden_lines(
lines: &mut Vec<Line<'_>>,
line_idx: &mut usize,
current_line_idx: usize,
count: usize,
theme: &Theme,
) {
let indicator = cursor_indicator_spaced(*line_idx, current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(theme)),
Span::styled(
format!(" ... {count} lines hidden ..."),
styles::dim_style(theme),
),
]));
*line_idx += 1;
}
pub(crate) fn render_collapsed_placeholder(
lines: &mut Vec<Line<'_>>,
line_idx: &mut usize,
current_line_idx: usize,
app: &App,
file: &travelagent_core::model::DiffFile,
) {
let (additions, deletions) = file.stat();
let changed = additions + deletions;
let file_idx = app
.diff_files
.iter()
.position(|f| std::ptr::eq(f, file))
.unwrap_or(0);
let reason_label = match app.auto_collapse_reason_for(file_idx) {
Some(reason) => reason.label(),
None => "manual",
};
let indicator = cursor_indicator_spaced(*line_idx, current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled(
format!(
" File collapsed \u{2014} {changed} lines, {reason_label}. Press `z` to expand."
),
styles::dim_style(&app.theme),
),
]));
*line_idx += 1;
}
pub(crate) fn comment_type_presentation(
app: &App,
comment_type: &travelagent_core::model::CommentType,
) -> comment_panel::CommentTypePresentation {
comment_panel::CommentTypePresentation {
label: app.comment_type_label(comment_type),
color: app.comment_type_color(comment_type),
}
}
pub(crate) fn render_orphans_section(
lines: &mut Vec<Line<'_>>,
line_idx: &mut usize,
current_line_idx: usize,
app: &App,
header_label: String,
orphans: &[travelagent_core::model::Comment],
) {
use travelagent_core::model::AnchorState;
let theme = &app.theme;
let indicator = cursor_indicator_spaced(*line_idx, current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(theme)),
Span::styled(header_label, styles::dim_style(theme)),
]));
*line_idx += 1;
for comment in orphans {
let (was_line, last_seen) = match comment.anchor.as_ref() {
Some(AnchorState::Orphaned {
was_line,
last_seen_content,
..
}) => (*was_line, last_seen_content.clone()),
_ => (0, String::new()),
};
let preview: String = last_seen.chars().take(60).collect();
let hdr_indicator = cursor_indicator(*line_idx, current_line_idx);
lines.push(Line::from(vec![
Span::styled(hdr_indicator, styles::current_line_indicator_style(theme)),
Span::styled(
format!(" [ORPHANED was line {was_line}] "),
styles::dim_style(theme),
),
Span::styled(format!("\"{preview}\""), styles::dim_style(theme)),
]));
*line_idx += 1;
let comment_lines = comment_panel::format_comment_lines(
theme,
comment_type_presentation(app, &comment.comment_type),
&comment.content,
None,
);
for mut cl in comment_lines {
let ind = cursor_indicator(*line_idx, current_line_idx);
cl.spans.insert(
0,
Span::styled(ind, styles::current_line_indicator_style(theme)),
);
lines.push(cl);
*line_idx += 1;
}
}
let ind = cursor_indicator(*line_idx, current_line_idx);
lines.push(Line::from(Span::styled(
ind,
styles::current_line_indicator_style(theme),
)));
*line_idx += 1;
}
pub(crate) fn render_session_orphans(
lines: &mut Vec<Line<'_>>,
line_idx: &mut usize,
current_line_idx: usize,
app: &App,
) {
use std::collections::HashSet;
let diff_paths: HashSet<_> = app
.diff_files
.iter()
.map(|f| f.display_path_lossy().clone())
.collect();
let mut gone: Vec<_> = app
.engine
.session()
.files
.iter()
.filter(|(p, _)| !diff_paths.contains(*p))
.filter(|(_, r)| !r.orphaned_comments.is_empty())
.collect();
gone.sort_by(|a, b| a.0.cmp(b.0));
let total: usize = gone.iter().map(|(_, r)| r.orphaned_comments.len()).sum();
if total == 0 {
return;
}
let mut flat: Vec<travelagent_core::model::Comment> = Vec::with_capacity(total);
for (_, r) in &gone {
for c in &r.orphaned_comments {
flat.push(c.clone());
}
}
let label = format!("── Orphaned comments (removed files) ({total}) ──");
render_orphans_section(lines, line_idx, current_line_idx, app, label, &flat);
}
pub(crate) fn scroll_comment_input_into_view(
scroll_offset: &mut usize,
box_range: Option<(usize, usize)>,
cursor_line: Option<usize>,
viewport_height: usize,
total_lines: usize,
) {
let Some((box_start, box_end)) = box_range else {
return;
};
if viewport_height == 0 {
return;
}
let box_height = box_end.saturating_sub(box_start) + 1;
if box_height <= viewport_height {
if box_start < *scroll_offset {
*scroll_offset = box_start;
} else if box_end >= *scroll_offset + viewport_height {
*scroll_offset = box_end + 1 - viewport_height;
}
} else if let Some(cursor) = cursor_line {
if cursor < *scroll_offset {
*scroll_offset = cursor;
} else if cursor >= *scroll_offset + viewport_height {
*scroll_offset = cursor + 1 - viewport_height;
}
}
let max_scroll = total_lines.saturating_sub(viewport_height);
if *scroll_offset > max_scroll {
*scroll_offset = max_scroll;
}
}
pub(crate) fn truncate_or_pad(s: &str, width: usize) -> String {
let char_count = s.chars().count();
if char_count > width {
s.chars().take(width.saturating_sub(3)).collect::<String>() + "..."
} else {
format!("{s:width$}")
}
}
pub(crate) fn truncate_or_pad_spans(
spans: &[(travelagent_core::style::StyleHint, String)],
width: usize,
base_style: Style,
) -> Vec<Span<'static>> {
let total_width: usize = spans.iter().map(|(_, text)| text.width()).sum();
if total_width > width {
let mut result = Vec::new();
let mut remaining = width.saturating_sub(3);
for (hint, text) in spans {
if remaining == 0 {
break;
}
let style = styles::style_hint_to_ratatui(*hint);
let text_width = text.width();
if text_width <= remaining {
result.push(Span::styled(text.clone(), style));
remaining -= text_width;
} else {
let mut truncated = String::new();
let mut current_width = 0;
for c in text.chars() {
let char_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
if current_width + char_width > remaining {
break;
}
truncated.push(c);
current_width += char_width;
}
if !truncated.is_empty() {
result.push(Span::styled(truncated, style));
}
remaining = 0;
}
}
result.push(Span::styled("...".to_string(), base_style));
result
} else if total_width < width {
let mut result: Vec<Span> = spans
.iter()
.map(|(hint, text)| Span::styled(text.clone(), styles::style_hint_to_ratatui(*hint)))
.collect();
let padding = " ".repeat(width - total_width);
result.push(Span::styled(padding, base_style));
result
} else {
spans
.iter()
.map(|(hint, text)| Span::styled(text.clone(), styles::style_hint_to_ratatui(*hint)))
.collect()
}
}
pub(crate) fn apply_horizontal_scroll(line: Line, scroll_x: usize) -> Line {
if scroll_x == 0 || line.spans.is_empty() {
return line;
}
let mut spans: Vec<Span> = line.spans.into_iter().collect();
let indicator = spans.remove(0);
let mut chars_to_skip = scroll_x;
let mut new_spans = vec![indicator];
for span in spans {
let content = span.content.to_string();
let char_count = content.chars().count();
if chars_to_skip >= char_count {
chars_to_skip -= char_count;
} else if chars_to_skip > 0 {
let new_content: String = content.chars().skip(chars_to_skip).collect();
chars_to_skip = 0;
new_spans.push(Span::styled(new_content, span.style));
} else {
new_spans.push(Span::styled(content, span.style));
}
}
Line::from(new_spans)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_not_scroll_when_comment_box_already_visible() {
let mut scroll = 0;
scroll_comment_input_into_view(&mut scroll, Some((5, 7)), Some(6), 10, 100);
assert_eq!(scroll, 0);
}
#[test]
fn should_scroll_down_when_comment_box_below_viewport() {
let mut scroll = 0;
scroll_comment_input_into_view(&mut scroll, Some((20, 22)), Some(21), 10, 100);
assert_eq!(scroll, 13);
}
#[test]
fn should_scroll_up_when_comment_box_above_viewport() {
let mut scroll = 20;
scroll_comment_input_into_view(&mut scroll, Some((5, 7)), Some(6), 10, 100);
assert_eq!(scroll, 5);
}
#[test]
fn should_scroll_to_cursor_when_box_taller_than_viewport() {
let mut scroll = 0;
scroll_comment_input_into_view(&mut scroll, Some((30, 49)), Some(45), 10, 100);
assert_eq!(scroll, 36);
}
#[test]
fn should_not_scroll_past_end_of_content() {
let mut scroll = 200;
scroll_comment_input_into_view(&mut scroll, Some((95, 97)), Some(96), 10, 100);
assert_eq!(scroll, 90);
}
#[test]
fn should_not_scroll_when_no_comment_box() {
let mut scroll = 42;
scroll_comment_input_into_view(&mut scroll, None, None, 10, 100);
assert_eq!(scroll, 42);
}
#[test]
fn should_handle_box_partially_below_viewport() {
let mut scroll = 0;
scroll_comment_input_into_view(&mut scroll, Some((8, 10)), Some(9), 10, 100);
assert_eq!(scroll, 1);
}
#[test]
fn expand_tabs_leaves_plain_text_unchanged() {
assert_eq!(expand_tabs("hello world", 4), "hello world");
assert_eq!(expand_tabs("", 4), "");
}
#[test]
fn expand_tabs_leading_tab_becomes_tab_width_spaces() {
assert_eq!(expand_tabs("\tfoo", 4), " foo");
assert_eq!(expand_tabs("\tfoo", 8), " foo");
}
#[test]
fn expand_tabs_mid_string_tab_aligns_to_next_column() {
assert_eq!(expand_tabs("ab\tc", 4), "ab c");
assert_eq!(expand_tabs("ab\tc", 8), "ab c");
}
#[test]
fn expand_tabs_multiple_tabs_on_one_line() {
assert_eq!(expand_tabs("\t\tx", 4), " x");
assert_eq!(expand_tabs("a\tb\tc", 2), "a b c");
}
#[test]
fn expand_tabs_zero_width_returns_input_unchanged() {
assert_eq!(expand_tabs("a\tb", 0), "a\tb");
}
#[test]
fn truncate_or_pad_spans_respects_unicode_display_width() {
use travelagent_core::style::StyleHint;
let hint = StyleHint::default();
let spans = vec![(hint, "日本語テスト".to_string())];
let out = truncate_or_pad_spans(&spans, 12, ratatui::style::Style::default());
let total_cols: usize = out
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
assert_eq!(total_cols, 12, "CJK-exact-fit must preserve both tokens");
let emojis = "🙂🙃🚀🎯";
let spans = vec![(hint, emojis.to_string())];
let out = truncate_or_pad_spans(&spans, 6, ratatui::style::Style::default());
let total_cols: usize = out
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
assert!(total_cols <= 6, "truncate must not exceed column budget");
let joined: String = out.iter().map(|s| s.content.as_ref()).collect();
assert!(
joined.ends_with("..."),
"truncated output must end with ellipsis, got {joined:?}"
);
let mixed = "hello 日本";
let spans = vec![(hint, mixed.to_string())];
let mixed_cols = UnicodeWidthStr::width(mixed); let out = truncate_or_pad_spans(&spans, 80, ratatui::style::Style::default());
let total_cols: usize = out
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
assert_eq!(
total_cols, 80,
"pad must land on exact column width (input was {mixed_cols} cols)"
);
}
}