use super::{
expand_tabs_in_spans, pending_tail_text, render_empty_state, slice_spans, spans_to_text,
spans_width, truncate_text, view_spans_to_text, wrap_count_for_spans, wrap_count_for_text,
TAB_WIDTH,
};
use crate::app::{is_conflict_marker, is_fold_line, AnimationPhase, App};
use crate::syntax::SyntaxSide;
use oyo_core::{LineKind, StepDirection, ViewLine, ViewSpanKind};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap},
Frame,
};
const GUTTER_WIDTH: u16 = 8;
fn hunk_overflow_wrapped_evolution(
view_lines: &[ViewLine],
hunk_idx: usize,
wrap_width: usize,
scroll_offset: usize,
viewport_height: usize,
) -> Option<(bool, bool)> {
let mut display_idx = 0usize;
let mut start: Option<usize> = None;
let mut end: Option<usize> = None;
for line in view_lines.iter() {
if matches!(line.kind, LineKind::Deleted | LineKind::PendingDelete) {
continue;
}
let text = view_spans_to_text(&line.spans);
let wrap_count = wrap_count_for_text(&text, wrap_width).max(1);
if line.hunk_index == Some(hunk_idx) {
if start.is_none() {
start = Some(display_idx);
}
end = Some(display_idx.saturating_add(wrap_count.saturating_sub(1)));
}
display_idx = display_idx.saturating_add(wrap_count);
}
let (start, end) = match (start, end) {
(Some(start), Some(end)) => (start, end),
_ => return None,
};
let visible_start = scroll_offset;
let visible_end = scroll_offset.saturating_add(viewport_height.saturating_sub(1));
Some((start < visible_start, end > visible_end))
}
pub fn render_evolution(frame: &mut Frame, app: &mut App, area: Rect) {
let visible_height = area.height as usize;
let visible_width = area.width.saturating_sub(GUTTER_WIDTH) as usize;
if !app.line_wrap {
app.clamp_horizontal_scroll_cached(visible_width);
}
if app.current_file_is_binary() {
render_empty_state(frame, area, &app.theme, false, true);
return;
}
let primary_marker = app.primary_marker.clone();
let extent_marker = app.extent_marker.clone();
if app.line_wrap {
app.handle_search_scroll_if_needed(visible_height);
} else {
app.ensure_active_visible_if_needed(visible_height);
}
let animation_frame = app.animation_frame();
let show_extent = app.stepping && !app.multi_diff.current_navigator().state().is_at_start();
app.multi_diff
.current_navigator()
.set_show_hunk_extent_while_stepping(show_extent);
let view_lines = app.current_view_with_frame(animation_frame);
let mut scroll_offset = app.render_scroll_offset();
let debug_enabled = super::view_debug_enabled();
if debug_enabled {
crate::syntax::syntax_debug_reset();
}
let step_direction = app.multi_diff.current_step_direction();
let mut display_len = 0usize;
let mut clamped_scroll = false;
if !app.line_wrap {
let (len, _) = crate::app::display_metrics(
&view_lines,
app.view_mode,
app.animation_phase,
scroll_offset,
step_direction,
app.split_align_lines,
);
let total_len = app.render_total_lines(len);
let scroll_before = app.scroll_offset;
app.clamp_scroll(total_len, visible_height, app.allow_overscroll());
clamped_scroll = app.scroll_offset != scroll_before;
display_len = total_len;
}
if clamped_scroll {
scroll_offset = app.render_scroll_offset();
}
let debug_target = app.syntax_scope_target(&view_lines);
let pending_insert_only = if app.stepping {
app.pending_insert_only_in_current_hunk()
} else {
0
};
let current_hunk = app.multi_diff.current_navigator().state().current_hunk;
let show_virtual = app.allow_virtual_lines();
let pending_text = if show_virtual && pending_insert_only > 0 {
Some(pending_tail_text(pending_insert_only))
} else {
None
};
let mut parts: Vec<(String, bool)> = Vec::new();
if let Some(pending) = pending_text {
parts.push((pending, true));
}
if let Some(hint) = app.last_step_hint_text() {
parts.push((hint.to_string(), true));
}
if let Some(hint) = app.hunk_edge_hint_text() {
parts.push((hint.to_string(), true));
}
if let Some(hint) = app.blame_hunk_hint_text() {
parts.push((hint.to_string(), false));
}
let virtual_text = if show_virtual && !parts.is_empty() {
Some(
parts
.into_iter()
.map(|(text, _)| text)
.collect::<Vec<_>>()
.join(" • "),
)
} else {
None
};
let (overflow_above, overflow_below) = if virtual_text.is_some() {
if app.line_wrap {
hunk_overflow_wrapped_evolution(
&view_lines,
current_hunk,
visible_width,
scroll_offset,
visible_height,
)
.unwrap_or((false, false))
} else {
app.hunk_hint_overflow(current_hunk, visible_height)
.unwrap_or((false, false))
}
} else {
(false, false)
};
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(GUTTER_WIDTH), Constraint::Min(0)])
.split(area);
let gutter_area = chunks[0];
let content_area = chunks[1];
let mut gutter_lines: Vec<Line> = Vec::new();
let mut content_lines: Vec<Line> = Vec::new();
let mut display_line_num = 0usize;
let mut max_line_width: usize = 0;
let wrap_width = visible_width;
let syntax_window = if app.line_wrap {
Some(super::syntax_highlight_window(
scroll_offset,
visible_height,
))
} else {
None
};
let warmup_window = super::syntax_highlight_window(scroll_offset, visible_height);
app.begin_syntax_warmup_frame();
let mut primary_display_idx: Option<usize> = None;
let mut active_display_idx: Option<usize> = None;
let hunk_preview_mode = app.multi_diff.current_navigator().state().hunk_preview_mode;
let animation_phase = app.animation_phase;
let mut has_visible = false;
for line in view_lines.iter() {
match line.kind {
LineKind::Deleted => {}
LineKind::PendingDelete => {
if hunk_preview_mode {
continue;
}
if !line.is_active_change {
continue;
}
if animation_phase != AnimationPhase::Idle {
has_visible = true;
break;
}
}
_ => {
has_visible = true;
break;
}
}
}
let show_deleted_fallback = !has_visible;
let is_visible = |line: &ViewLine| -> bool {
match line.kind {
LineKind::Deleted => show_deleted_fallback,
LineKind::PendingDelete => {
if show_deleted_fallback {
return true;
}
if hunk_preview_mode {
return false;
}
if !line.is_active_change {
return false;
}
animation_phase != AnimationPhase::Idle
}
_ => true,
}
};
let step_direction = app.multi_diff.current_step_direction();
let primary_raw_idx = view_lines.iter().position(|line| line.is_primary_active);
let visible_indices: Vec<usize> = view_lines
.iter()
.enumerate()
.filter_map(|(idx, line)| if is_visible(line) { Some(idx) } else { None })
.collect();
let mut next_visible_hunk: Vec<Option<usize>> = vec![None; view_lines.len()];
let mut next_hunk: Option<usize> = None;
for idx in visible_indices.iter().rev() {
next_visible_hunk[*idx] = next_hunk;
if view_lines[*idx].hunk_index.is_some() {
next_hunk = view_lines[*idx].hunk_index;
}
}
let mut prev_visible_hunk: Option<usize> = None;
let mut virtual_inserted = false;
let fallback_primary = primary_raw_idx.and_then(|idx| {
if visible_indices.is_empty() {
return None;
}
if is_visible(&view_lines[idx]) {
return Some(idx);
}
let (mut before, mut after) = (None, None);
for &vis in &visible_indices {
if vis < idx {
before = Some(vis);
} else if vis > idx {
after = Some(vis);
break;
}
}
let prefer_after = !matches!(step_direction, StepDirection::Backward);
if prefer_after {
after.or(before)
} else {
before.or(after)
}
});
let cursor_in_target = view_lines.iter().enumerate().any(|(raw_idx, line)| {
let is_primary = line.is_primary_active || fallback_primary == Some(raw_idx);
is_primary && line.hunk_index == Some(current_hunk)
});
let cursor_visible = if app.line_wrap {
cursor_in_target
} else {
let mut display_idx = 0usize;
let mut cursor_display = None;
for (raw_idx, line) in view_lines.iter().enumerate() {
if !is_visible(line) {
continue;
}
let is_primary = line.is_primary_active || fallback_primary == Some(raw_idx);
if is_primary {
cursor_display = Some(display_idx);
break;
}
display_idx += 1;
}
cursor_display
.map(|idx| idx >= scroll_offset && idx < scroll_offset.saturating_add(visible_height))
.unwrap_or(false)
};
let mut prev_visible_hunk_map: Vec<Option<usize>> = vec![None; view_lines.len()];
let mut prev_hunk: Option<usize> = None;
for idx in visible_indices.iter() {
prev_visible_hunk_map[*idx] = prev_hunk;
if view_lines[*idx].hunk_index.is_some() {
prev_hunk = view_lines[*idx].hunk_index;
}
}
let cursor_idx = visible_indices.iter().copied().find(|idx| {
let line = &view_lines[*idx];
let is_primary = line.is_primary_active || fallback_primary == Some(*idx);
is_primary && line.hunk_index == Some(current_hunk)
});
let cursor_at_first = cursor_idx
.map(|idx| prev_visible_hunk_map[idx] != Some(current_hunk))
.unwrap_or(false);
let cursor_at_last = cursor_idx
.map(|idx| next_visible_hunk[idx] != Some(current_hunk))
.unwrap_or(false);
let fully_visible = !overflow_above && !overflow_below;
let mut force_top = overflow_below && !overflow_above;
let mut force_bottom = overflow_above && !overflow_below;
if fully_visible {
if cursor_at_first {
force_top = true;
} else if cursor_at_last {
force_bottom = true;
}
}
let mut prefer_cursor =
cursor_in_target && cursor_visible && !force_top && !force_bottom && !fully_visible;
if pending_insert_only > 0 && cursor_in_target && cursor_visible {
prefer_cursor = true;
}
let query = app.search_query().trim().to_ascii_lowercase();
let has_query = !query.is_empty();
let mut review_preview_rows: Vec<(usize, usize, String)> = Vec::new();
let mut review_preview_before_idx: std::collections::HashMap<usize, Vec<(String, String)>> =
std::collections::HashMap::new();
let mut review_preview_after_idx: std::collections::HashMap<usize, Vec<(String, String)>> =
std::collections::HashMap::new();
if app.review_mode()
&& !app.review_editor_active()
&& app.view_mode == crate::app::ViewMode::Evolution
{
for overlay in app.review_comment_overlays_for_current_file() {
let text = app.review_preview_hint_text(&overlay);
if overlay.is_hunk {
review_preview_before_idx
.entry(overlay.display_idx)
.or_default()
.push((overlay.anchor_key, text));
} else {
review_preview_after_idx
.entry(overlay.display_idx)
.or_default()
.push((overlay.anchor_key, text));
}
}
}
for (raw_idx, view_line) in view_lines.iter().enumerate() {
if !is_visible(view_line) {
continue;
}
if app.line_wrap {
let display_idx = display_len;
let is_primary = view_line.is_primary_active || fallback_primary == Some(raw_idx);
if is_primary && primary_display_idx.is_none() {
primary_display_idx = Some(display_idx);
}
if (view_line.is_active || is_primary) && active_display_idx.is_none() {
active_display_idx = Some(display_idx);
}
}
display_line_num += 1;
let display_idx = display_line_num - 1;
if !app.line_wrap && display_line_num <= scroll_offset {
continue;
}
if !app.line_wrap && gutter_lines.len() >= visible_height {
break;
}
let line_hunk = view_line.hunk_index;
let is_first_in_hunk = line_hunk.is_some() && prev_visible_hunk != line_hunk;
let is_last_in_hunk = line_hunk.is_some() && next_visible_hunk[raw_idx] != line_hunk;
if let Some(text) = virtual_text.as_ref() {
if !virtual_inserted
&& !prefer_cursor
&& force_top
&& line_hunk == Some(current_hunk)
&& is_first_in_hunk
{
let virtual_style = Style::default()
.fg(app.theme.text_muted)
.add_modifier(Modifier::ITALIC);
let mut virtual_spans = vec![Span::styled(text.clone(), virtual_style)];
virtual_spans = expand_tabs_in_spans(&virtual_spans, TAB_WIDTH);
let virtual_width = spans_width(&virtual_spans);
max_line_width = max_line_width.max(virtual_width);
let virtual_wrap = if app.line_wrap {
wrap_count_for_spans(&virtual_spans, wrap_width)
} else {
1
};
if app.line_wrap {
display_len += virtual_wrap;
}
let mut display_virtual = virtual_spans;
if !app.line_wrap {
display_virtual =
slice_spans(&display_virtual, app.horizontal_scroll, visible_width);
}
content_lines.push(Line::from(display_virtual));
gutter_lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::raw(" "),
Span::raw(" "),
Span::raw(" "),
]));
if app.line_wrap && virtual_wrap > 1 {
for _ in 1..virtual_wrap {
gutter_lines.push(Line::from(Span::raw(" ")));
}
}
virtual_inserted = true;
}
}
if let Some(previews) = review_preview_before_idx.get(&display_idx) {
for (anchor_key, preview_text) in previews {
let virtual_style = Style::default()
.fg(app.theme.text_muted)
.add_modifier(Modifier::ITALIC);
let mut virtual_spans = vec![Span::styled(preview_text.clone(), virtual_style)];
virtual_spans = expand_tabs_in_spans(&virtual_spans, TAB_WIDTH);
let virtual_width = spans_width(&virtual_spans);
max_line_width = max_line_width.max(virtual_width);
let virtual_wrap = if app.line_wrap {
wrap_count_for_spans(&virtual_spans, wrap_width)
} else {
1
};
let row_idx = if app.line_wrap {
display_len
} else {
content_lines.len()
};
if app.line_wrap {
display_len += virtual_wrap;
}
let mut display_virtual = virtual_spans;
if !app.line_wrap {
display_virtual =
slice_spans(&display_virtual, app.horizontal_scroll, visible_width);
}
content_lines.push(Line::from(display_virtual));
gutter_lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::raw(" "),
Span::raw(" "),
Span::raw(" "),
]));
if app.line_wrap && virtual_wrap > 1 {
for _ in 1..virtual_wrap {
gutter_lines.push(Line::from(Span::raw(" ")));
}
}
review_preview_rows.push((row_idx, virtual_wrap, anchor_key.clone()));
}
}
let fold_line = is_fold_line(view_line);
let line_num = view_line.new_line.or(view_line.old_line).unwrap_or(0);
let line_num_str = if fold_line || line_num == 0 {
" ".to_string()
} else {
format!("{:4}", line_num)
};
let line_num_style = match view_line.kind {
LineKind::Context => Style::default().fg(app.theme.diff_line_number),
LineKind::Inserted | LineKind::PendingInsert => {
let rgb = crate::color::gradient_color(&app.theme.insert, 0.5);
Style::default().fg(Color::Rgb(rgb.r, rgb.g, rgb.b))
}
LineKind::Modified | LineKind::PendingModify => {
let rgb = crate::color::gradient_color(&app.theme.modify, 0.5);
Style::default().fg(Color::Rgb(rgb.r, rgb.g, rgb.b))
}
LineKind::PendingDelete => {
if view_line.is_active_change && app.animation_phase != AnimationPhase::Idle {
let mut t = crate::color::animation_t_linear(
app.animation_phase,
app.animation_progress,
);
if app.is_backward_animation() {
t = 1.0 - t;
}
let t = crate::color::ease_out(t);
let color = crate::color::lerp_rgb_color(
app.theme.diff_line_number,
app.theme.delete_base(),
t,
);
Style::default().fg(color)
} else {
Style::default().fg(app.theme.diff_line_number)
}
}
LineKind::Deleted => Style::default().fg(app.theme.text_muted),
};
let is_primary = view_line.is_primary_active || fallback_primary == Some(raw_idx);
let show_extent = super::show_extent_marker(app, view_line);
let (active_marker, active_style) = if is_primary {
(
primary_marker.as_str(),
Style::default()
.fg(app.theme.primary)
.add_modifier(Modifier::BOLD),
)
} else if show_extent {
(
extent_marker.as_str(),
super::extent_marker_style(
app,
view_line.kind,
view_line.has_changes,
view_line.old_line,
view_line.new_line,
),
)
} else {
(" ", Style::default())
};
let gutter_spans = vec![
Span::styled(active_marker, active_style),
Span::styled(line_num_str, line_num_style),
Span::styled(" ", Style::default()),
Span::styled(" ", Style::default()),
Span::styled(" ", Style::default()),
];
gutter_lines.push(Line::from(gutter_spans));
let mut content_spans: Vec<Span<'static>> = Vec::new();
let mut used_syntax = false;
let (line_display_start, line_display_end) = if app.line_wrap {
let text = view_spans_to_text(&view_line.spans);
let wrap_hint = wrap_count_for_text(&text, wrap_width).max(1);
let line_display_start = display_len;
let line_display_end = line_display_start.saturating_add(wrap_hint.saturating_sub(1));
(line_display_start, line_display_end)
} else {
(display_line_num, display_line_num)
};
let in_syntax_window = if app.line_wrap {
super::in_syntax_window(syntax_window, line_display_start, line_display_end)
} else {
true
};
let in_warmup_window = if app.line_wrap {
super::in_syntax_window(Some(warmup_window), line_display_start, line_display_end)
} else {
true
};
let allow_syntax = in_syntax_window
&& app.syntax_enabled()
&& match app.evo_syntax {
crate::config::EvoSyntaxMode::Context => !view_line.has_changes,
crate::config::EvoSyntaxMode::Full => !view_line.is_active_change,
};
if allow_syntax {
let use_old = match view_line.kind {
LineKind::Deleted | LineKind::PendingDelete => true,
LineKind::Inserted
| LineKind::Modified
| LineKind::PendingInsert
| LineKind::PendingModify => false,
LineKind::Context => view_line.has_changes,
};
let side = if use_old {
SyntaxSide::Old
} else {
SyntaxSide::New
};
let line_num = if use_old {
view_line.old_line.or(view_line.new_line)
} else {
view_line.new_line.or(view_line.old_line)
};
let line_num = line_num.filter(|num| *num > 0);
if in_warmup_window {
if let Some(line_num) = line_num {
app.record_syntax_warmup_line(side, line_num);
}
}
if let Some(spans) = app.syntax_spans_for_line(side, line_num) {
content_spans = spans;
used_syntax = true;
}
}
if !used_syntax {
for view_span in &view_line.spans {
let style = get_evolution_span_style(
view_span.kind,
view_line.kind,
view_line.is_active,
app,
);
if app.strikethrough_deletions
&& matches!(
view_span.kind,
ViewSpanKind::Deleted | ViewSpanKind::PendingDelete
)
{
let text = &view_span.text;
let trimmed = text.trim_start();
let leading_ws_len = text.len() - trimmed.len();
if leading_ws_len > 0 && !trimmed.is_empty() {
let ws_style = style.remove_modifier(Modifier::CROSSED_OUT);
content_spans
.push(Span::styled(text[..leading_ws_len].to_string(), ws_style));
content_spans.push(Span::styled(trimmed.to_string(), style));
} else {
content_spans.push(Span::styled(view_span.text.clone(), style));
}
} else {
content_spans.push(Span::styled(view_span.text.clone(), style));
}
}
}
let line_text = spans_to_text(&content_spans);
let is_active_match = app.search_target() == Some(display_idx)
&& has_query
&& line_text.to_ascii_lowercase().contains(&query);
content_spans = app.highlight_search_spans(content_spans, &line_text, is_active_match);
if is_conflict_marker(view_line) {
content_spans = content_spans
.into_iter()
.map(|span| {
let mut style = span.style;
style = style.fg(app.theme.warning).add_modifier(Modifier::BOLD);
Span::styled(span.content, style)
})
.collect();
}
content_spans = expand_tabs_in_spans(&content_spans, TAB_WIDTH);
let line_width = spans_width(&content_spans);
max_line_width = max_line_width.max(line_width);
let wrap_count = if app.line_wrap {
wrap_count_for_spans(&content_spans, wrap_width)
} else {
1
};
if app.line_wrap {
display_len += wrap_count;
}
let mut display_spans = content_spans;
if !app.line_wrap {
display_spans = slice_spans(&display_spans, app.horizontal_scroll, visible_width);
}
content_lines.push(Line::from(display_spans));
if app.line_wrap && wrap_count > 1 {
for _ in 1..wrap_count {
gutter_lines.push(Line::from(Span::raw(" ")));
}
}
if let Some(previews) = review_preview_after_idx.get(&display_idx) {
for (anchor_key, preview_text) in previews {
let virtual_style = Style::default()
.fg(app.theme.text_muted)
.add_modifier(Modifier::ITALIC);
let mut virtual_spans = vec![Span::styled(preview_text.clone(), virtual_style)];
virtual_spans = expand_tabs_in_spans(&virtual_spans, TAB_WIDTH);
let virtual_width = spans_width(&virtual_spans);
max_line_width = max_line_width.max(virtual_width);
let virtual_wrap = if app.line_wrap {
wrap_count_for_spans(&virtual_spans, wrap_width)
} else {
1
};
let row_idx = if app.line_wrap {
display_len
} else {
content_lines.len()
};
if app.line_wrap {
display_len += virtual_wrap;
}
let mut display_virtual = virtual_spans;
if !app.line_wrap {
display_virtual =
slice_spans(&display_virtual, app.horizontal_scroll, visible_width);
}
content_lines.push(Line::from(display_virtual));
gutter_lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::raw(" "),
Span::raw(" "),
Span::raw(" "),
]));
if app.line_wrap && virtual_wrap > 1 {
for _ in 1..virtual_wrap {
gutter_lines.push(Line::from(Span::raw(" ")));
}
}
review_preview_rows.push((row_idx, virtual_wrap, anchor_key.clone()));
}
}
if let Some(text) = virtual_text.as_ref() {
if !virtual_inserted
&& prefer_cursor
&& line_hunk == Some(current_hunk)
&& (view_line.is_primary_active || fallback_primary == Some(raw_idx))
{
let virtual_style = Style::default()
.fg(app.theme.text_muted)
.add_modifier(Modifier::ITALIC);
let mut virtual_spans = vec![Span::styled(text.clone(), virtual_style)];
virtual_spans = expand_tabs_in_spans(&virtual_spans, TAB_WIDTH);
let virtual_width = spans_width(&virtual_spans);
max_line_width = max_line_width.max(virtual_width);
let virtual_wrap = if app.line_wrap {
wrap_count_for_spans(&virtual_spans, wrap_width)
} else {
1
};
if app.line_wrap {
display_len += virtual_wrap;
}
let mut display_virtual = virtual_spans;
if !app.line_wrap {
display_virtual =
slice_spans(&display_virtual, app.horizontal_scroll, visible_width);
}
content_lines.push(Line::from(display_virtual));
gutter_lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::raw(" "),
Span::raw(" "),
Span::raw(" "),
]));
if app.line_wrap && virtual_wrap > 1 {
for _ in 1..virtual_wrap {
gutter_lines.push(Line::from(Span::raw(" ")));
}
}
virtual_inserted = true;
}
}
if let Some(text) = virtual_text.as_ref() {
if !virtual_inserted
&& !prefer_cursor
&& !force_top
&& line_hunk == Some(current_hunk)
&& is_last_in_hunk
{
let virtual_style = Style::default()
.fg(app.theme.text_muted)
.add_modifier(Modifier::ITALIC);
let mut virtual_spans = vec![Span::styled(text.clone(), virtual_style)];
virtual_spans = expand_tabs_in_spans(&virtual_spans, TAB_WIDTH);
let virtual_width = spans_width(&virtual_spans);
max_line_width = max_line_width.max(virtual_width);
let virtual_wrap = if app.line_wrap {
wrap_count_for_spans(&virtual_spans, wrap_width)
} else {
1
};
if app.line_wrap {
display_len += virtual_wrap;
}
let mut display_virtual = virtual_spans;
if !app.line_wrap {
display_virtual =
slice_spans(&display_virtual, app.horizontal_scroll, visible_width);
}
content_lines.push(Line::from(display_virtual));
gutter_lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::raw(" "),
Span::raw(" "),
Span::raw(" "),
]));
if app.line_wrap && virtual_wrap > 1 {
for _ in 1..virtual_wrap {
gutter_lines.push(Line::from(Span::raw(" ")));
}
}
virtual_inserted = true;
}
}
prev_visible_hunk = line_hunk;
if let Some(hint_text) = app.step_edge_hint_for_change(view_line.change_id) {
let virtual_style = Style::default()
.fg(app.theme.text_muted)
.add_modifier(Modifier::ITALIC);
let mut virtual_spans = vec![Span::styled(hint_text.to_string(), virtual_style)];
virtual_spans = expand_tabs_in_spans(&virtual_spans, TAB_WIDTH);
let virtual_width = spans_width(&virtual_spans);
max_line_width = max_line_width.max(virtual_width);
let virtual_wrap = if app.line_wrap {
wrap_count_for_spans(&virtual_spans, wrap_width)
} else {
1
};
if app.line_wrap {
display_len += virtual_wrap;
}
let mut display_virtual = virtual_spans;
if !app.line_wrap {
display_virtual =
slice_spans(&display_virtual, app.horizontal_scroll, visible_width);
}
content_lines.push(Line::from(display_virtual));
gutter_lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::raw(" "),
Span::raw(" "),
Span::raw(" "),
]));
if app.line_wrap && virtual_wrap > 1 {
for _ in 1..virtual_wrap {
gutter_lines.push(Line::from(Span::raw(" ")));
}
}
}
if let Some(hint_text) = app.blame_step_hint_for_change(view_line.change_id) {
let virtual_style = Style::default()
.fg(app.theme.text_muted)
.add_modifier(Modifier::ITALIC);
let mut virtual_spans = vec![Span::styled(hint_text.to_string(), virtual_style)];
virtual_spans = expand_tabs_in_spans(&virtual_spans, TAB_WIDTH);
let virtual_width = spans_width(&virtual_spans);
max_line_width = max_line_width.max(virtual_width);
let virtual_wrap = if app.line_wrap {
wrap_count_for_spans(&virtual_spans, wrap_width)
} else {
1
};
if app.line_wrap {
display_len += virtual_wrap;
}
let mut display_virtual = virtual_spans;
if !app.line_wrap {
display_virtual =
slice_spans(&display_virtual, app.horizontal_scroll, visible_width);
}
content_lines.push(Line::from(display_virtual));
gutter_lines.push(Line::from(Span::raw(" ")));
if app.line_wrap && virtual_wrap > 1 {
for _ in 1..virtual_wrap {
gutter_lines.push(Line::from(Span::raw(" ")));
}
}
}
if let Some((debug_idx, ref label)) = debug_target {
if debug_idx == display_idx {
let debug_text = truncate_text(&format!(" {}", label), visible_width);
let debug_style = Style::default().fg(app.theme.text_muted);
let debug_wrap = if app.line_wrap {
wrap_count_for_text(&debug_text, wrap_width)
} else {
1
};
gutter_lines.push(Line::from(Span::raw(" ")));
content_lines.push(Line::from(Span::styled(debug_text, debug_style)));
if app.line_wrap {
display_len += debug_wrap;
if debug_wrap > 1 {
for _ in 1..debug_wrap {
gutter_lines.push(Line::from(Span::raw(" ")));
}
}
}
}
}
}
app.commit_syntax_warmup_frame();
if app.line_wrap {
app.ensure_active_visible_if_needed_wrapped(
visible_height,
display_len,
primary_display_idx.or(active_display_idx),
);
let total_len = app.render_total_lines(display_len);
let scroll_before = app.scroll_offset;
app.clamp_scroll(total_len, visible_height, app.allow_overscroll());
if app.scroll_offset != scroll_before {
scroll_offset = app.render_scroll_offset();
}
}
app.clamp_horizontal_scroll(max_line_width, visible_width);
app.set_current_max_line_width(max_line_width);
if debug_enabled {
let extra = super::syntax_debug_extra();
super::maybe_log_view_debug(
app,
view_lines.as_ref(),
"evolution",
visible_height,
visible_width,
scroll_offset,
extra,
);
}
let bg_style = app.theme.background.map(|bg| Style::default().bg(bg));
let mut gutter_paragraph = if app.line_wrap {
Paragraph::new(gutter_lines).scroll((scroll_offset as u16, 0))
} else {
Paragraph::new(gutter_lines)
};
if let Some(style) = bg_style {
gutter_paragraph = gutter_paragraph.style(style);
}
frame.render_widget(gutter_paragraph, gutter_area);
if content_lines.is_empty() {
let has_changes = !app
.multi_diff
.current_navigator()
.diff()
.significant_changes
.is_empty();
render_empty_state(
frame,
content_area,
&app.theme,
has_changes,
app.current_file_is_binary(),
);
} else {
let mut content_paragraph = if app.line_wrap {
Paragraph::new(content_lines)
.wrap(Wrap { trim: false })
.scroll((scroll_offset as u16, 0))
} else {
Paragraph::new(content_lines)
};
if let Some(style) = bg_style {
content_paragraph = content_paragraph.style(style);
}
frame.render_widget(content_paragraph, content_area);
if app.review_mode()
&& !app.review_editor_active()
&& app.view_mode == crate::app::ViewMode::Evolution
{
let viewport_start = if app.line_wrap { scroll_offset } else { 0 };
let viewport_end = viewport_start.saturating_add(content_area.height as usize);
for (row_idx, row_span, anchor_key) in &review_preview_rows {
let start = *row_idx;
let end = start.saturating_add((*row_span).max(1));
let visible_start = start.max(viewport_start);
let visible_end = end.min(viewport_end);
if visible_start >= visible_end {
continue;
}
let local_row = visible_start.saturating_sub(viewport_start);
let height = (visible_end.saturating_sub(visible_start)) as u16;
if height == 0 {
continue;
}
app.add_review_preview_box(
content_area.x,
content_area.y.saturating_add(local_row as u16),
content_area.width,
height,
anchor_key.clone(),
);
}
}
if app.scrollbar_visible {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓"));
let visible_lines = content_area.height as usize;
if display_len > visible_lines {
let mut scrollbar_state =
ScrollbarState::new(display_len).position(app.scroll_offset);
frame.render_stateful_widget(
scrollbar,
area.inner(ratatui::layout::Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
}
}
}
}
fn get_evolution_span_style(
span_kind: ViewSpanKind,
line_kind: LineKind,
is_active: bool,
app: &App,
) -> Style {
let theme = &app.theme;
let is_modification = matches!(line_kind, LineKind::Modified | LineKind::PendingModify);
let added_bg = None;
let removed_bg = None;
let modified_bg = None;
match span_kind {
ViewSpanKind::Equal => Style::default().fg(theme.diff_context),
ViewSpanKind::Inserted => {
if is_modification {
super::modify_style(
AnimationPhase::Idle,
0.0,
false,
theme.modify_base(),
theme.diff_context,
modified_bg,
)
} else {
super::insert_style(
AnimationPhase::Idle,
0.0,
false,
theme.insert_base(),
theme.diff_context,
added_bg,
)
}
}
ViewSpanKind::Deleted => {
Style::default().fg(theme.text_muted)
}
ViewSpanKind::PendingInsert => {
if is_modification {
if is_active {
super::modify_style(
app.animation_phase,
app.animation_progress,
app.is_backward_animation(),
theme.modify_base(),
theme.diff_context,
modified_bg,
)
} else {
let mut style = Style::default().fg(theme.modify_dim());
if let Some(bg) = modified_bg {
style = style.bg(bg);
}
style
}
} else if is_active {
super::insert_style(
app.animation_phase,
app.animation_progress,
app.is_backward_animation(),
theme.insert_base(),
theme.diff_context,
added_bg,
)
} else {
let mut style = Style::default().fg(theme.insert_dim());
if let Some(bg) = added_bg {
style = style.bg(bg);
}
style
}
}
ViewSpanKind::PendingDelete => {
if is_active {
if is_modification {
super::modify_style(
app.animation_phase,
app.animation_progress,
app.is_backward_animation(),
theme.modify_base(),
theme.diff_context,
modified_bg,
)
} else {
super::delete_style(
app.animation_phase,
app.animation_progress,
app.is_backward_animation(),
app.strikethrough_deletions,
theme.delete_base(),
theme.diff_context,
removed_bg,
)
}
} else {
Style::default().fg(theme.text_muted)
}
}
}
}