use super::{
apply_line_bg, apply_spans_bg, clear_leading_ws_bg, diff_line_bg, expand_tabs_in_spans,
pad_spans_bg, pending_tail_text, render_empty_state, render_review_note_avatar,
review_note_block, slice_spans, spans_to_text, spans_width, truncate_text,
wrap_count_for_spans, wrap_count_for_text, TAB_WIDTH,
};
use crate::app::{
is_conflict_marker, is_fold_line, AnimationPhase, App, UnifiedRenderKey, UnifiedRenderModel,
};
use crate::color;
use crate::config::{DiffForegroundMode, DiffHighlightMode, ModifiedStepMode};
use crate::syntax::SyntaxSide;
use oyo_core::{AnimationFrame, Change, ChangeKind, LineKind, ViewLine, ViewSpan, ViewSpanKind};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Paragraph, Wrap},
Frame,
};
const GUTTER_WIDTH: u16 = 8; pub(crate) const TRAILING_REVIEW_SPACER_ROWS: usize = 12;
fn hunk_overflow_wrapped_unified(
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() {
let text = super::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))
}
fn hunk_overflow_unified(
view_lines: &[ViewLine],
hunk_idx: usize,
scroll_offset: usize,
viewport_height: usize,
view_mode: crate::app::ViewMode,
) -> 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() {
let is_visible = match view_mode {
crate::app::ViewMode::Evolution => {
!matches!(line.kind, LineKind::Deleted | LineKind::PendingDelete)
}
_ => true,
};
if !is_visible {
continue;
}
if line.hunk_index == Some(hunk_idx) {
if start.is_none() {
start = Some(display_idx);
}
end = Some(display_idx);
}
display_idx = display_idx.saturating_add(1);
}
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))
}
fn build_inline_modified_spans(
change: &Change,
app: &App,
include_equal: bool,
use_animation: bool,
) -> Option<Vec<Span<'static>>> {
let mut spans = Vec::new();
let mut has_old = false;
let mut has_new = false;
let (phase, progress, backward) = if use_animation {
(
app.animation_phase,
app.animation_progress,
app.is_backward_animation(),
)
} else {
(AnimationPhase::Idle, 1.0, false)
};
let use_bg = matches!(
app.diff_highlight,
DiffHighlightMode::Text | DiffHighlightMode::Word
);
let added_bg = if use_bg {
super::boost_inline_bg(app, app.theme.diff_added_bg, app.theme.insert_base())
} else {
None
};
let removed_bg = if use_bg {
super::boost_inline_bg(app, app.theme.diff_removed_bg, app.theme.delete_base())
} else {
None
};
let delete_style = super::delete_style(
phase,
progress,
backward,
app.strikethrough_deletions,
app.theme.delete_base(),
app.theme.diff_context,
removed_bg,
);
let insert_style = super::insert_style(
phase,
progress,
backward,
app.theme.insert_base(),
app.theme.insert_dim(),
added_bg,
);
let context_style = Style::default().fg(app.theme.diff_context);
for span in &change.spans {
match span.kind {
ChangeKind::Equal => {
if !include_equal {
continue;
}
spans.push(Span::styled(span.text.clone(), context_style));
}
ChangeKind::Delete => {
has_old = true;
let text = &span.text;
if app.strikethrough_deletions {
let trimmed = text.trim_start();
let leading_ws_len = text.len() - trimmed.len();
if leading_ws_len > 0 && !trimmed.is_empty() {
let ws_style = delete_style.remove_modifier(Modifier::CROSSED_OUT);
spans.push(Span::styled(text[..leading_ws_len].to_string(), ws_style));
spans.push(Span::styled(trimmed.to_string(), delete_style));
} else {
spans.push(Span::styled(text.to_string(), delete_style));
}
} else {
spans.push(Span::styled(text.to_string(), delete_style));
}
}
ChangeKind::Insert => {
has_new = true;
spans.push(Span::styled(span.text.clone(), insert_style));
}
ChangeKind::Replace => {
has_old = true;
has_new = true;
let text = &span.text;
if app.strikethrough_deletions {
let trimmed = text.trim_start();
let leading_ws_len = text.len() - trimmed.len();
if leading_ws_len > 0 && !trimmed.is_empty() {
let ws_style = delete_style.remove_modifier(Modifier::CROSSED_OUT);
spans.push(Span::styled(text[..leading_ws_len].to_string(), ws_style));
spans.push(Span::styled(trimmed.to_string(), delete_style));
} else {
spans.push(Span::styled(text.to_string(), delete_style));
}
} else {
spans.push(Span::styled(text.to_string(), delete_style));
}
spans.push(Span::styled(
span.new_text.clone().unwrap_or_else(|| span.text.clone()),
insert_style,
));
}
}
}
if has_old || has_new {
Some(spans)
} else {
None
}
}
fn build_modified_only_spans(
change: &Change,
app: &App,
use_animation: bool,
) -> Option<Vec<Span<'static>>> {
let mut spans = Vec::new();
let (phase, progress, backward) = if use_animation {
(
app.animation_phase,
app.animation_progress,
app.is_backward_animation(),
)
} else {
(AnimationPhase::Idle, 1.0, false)
};
let use_bg = matches!(
app.diff_highlight,
DiffHighlightMode::Text | DiffHighlightMode::Word
);
let modified_bg = if use_bg {
super::boost_inline_bg(app, app.theme.diff_modified_bg, app.theme.modify_base())
} else {
None
};
let modify_style = super::modify_style(
phase,
progress,
backward,
app.theme.modify_base(),
app.theme.diff_context,
modified_bg,
);
let context_style = Style::default().fg(app.theme.diff_context);
for span in &change.spans {
match span.kind {
ChangeKind::Equal => {
spans.push(Span::styled(span.text.clone(), context_style));
}
ChangeKind::Insert => {
spans.push(Span::styled(span.text.clone(), modify_style));
}
ChangeKind::Replace => {
spans.push(Span::styled(
span.new_text.clone().unwrap_or_else(|| span.text.clone()),
modify_style,
));
}
ChangeKind::Delete => {}
}
}
if spans.is_empty() {
None
} else {
Some(spans)
}
}
fn unified_render_key(
app: &mut App,
frame: AnimationFrame,
visible_height: usize,
wrap_width: usize,
scroll_offset: usize,
) -> UnifiedRenderKey {
let file_index = app.multi_diff.selected_index;
let placeholder_view = app.multi_diff.current_navigator_is_placeholder();
let peek_state = app.peek_state();
let state = app.multi_diff.current_navigator().state();
UnifiedRenderKey {
file_index,
frame,
current_step: state.current_step,
active_change: state.active_change,
cursor_change: state.cursor_change,
peek_state,
animating_hunk: state.animating_hunk,
step_direction: state.step_direction,
current_hunk: state.current_hunk,
last_nav_was_hunk: state.last_nav_was_hunk,
hunk_preview_mode: state.hunk_preview_mode,
preview_from_backward: state.preview_from_backward,
show_hunk_extent_while_stepping: state.show_hunk_extent_while_stepping,
placeholder_view,
fold_context: app.fold_context,
viewport_height: visible_height,
windowed: app.view_windowed(),
window_start: app.view_window_start(),
stepping: app.stepping,
line_wrap: app.line_wrap,
wrap_width,
scroll_offset,
horizontal_scroll: app.horizontal_scroll,
diff_bg: app.diff_bg,
diff_fg: app.diff_fg,
diff_highlight: app.diff_highlight,
diff_extent_marker: app.diff_extent_marker,
diff_extent_marker_scope: app.diff_extent_marker_scope,
diff_extent_marker_context: app.diff_extent_marker_context,
gutter_signs: app.gutter_signs,
strikethrough_deletions: app.strikethrough_deletions,
search_query: app.search_query().trim().to_string(),
search_active: app.search_active(),
search_revision: app.search_revision(),
diff_revision: app.diff_revision(),
syntax_mode: app.syntax_mode,
syntax_theme: app.syntax_theme.clone(),
theme_is_light: app.theme_is_light,
syntax_epoch: app.syntax_cache_epoch(),
step_edge_hint: app.step_edge_hint_active(),
hunk_edge_hint: app.hunk_edge_hint_active(),
blame_hunk_hint: app.blame_hunk_hint_text().map(|text| text.to_string()),
review_mode: app.review_mode(),
review_editor_active: app.review_editor_active(),
review_revision: app.review_revision(),
review_preview_hover: app.review_preview_hover.clone(),
review_preview_hover_id: app.review_preview_hover_id,
review_preview_edit_hover: app.review_preview_edit_hover,
review_preview_reply_hover: app.review_preview_reply_hover,
review_preview_resolve_hover: app.review_preview_resolve_hover,
review_preview_delete_hover: app.review_preview_delete_hover,
review_preview_overflow_hover: app.review_preview_overflow_hover,
review_preview_flash: app.review_preview_flash_key(),
fold_context_hover: app.fold_context_hover,
}
}
fn build_unified_render_model(
app: &mut App,
key: UnifiedRenderKey,
view_lines: &[ViewLine],
visible_height: usize,
visible_width: usize,
scroll_offset: usize,
blame_extra_rows: Option<&[usize]>,
) -> UnifiedRenderModel {
app.clear_review_unified_line_rows();
let mut review_preview_before_idx: std::collections::HashMap<
usize,
Vec<(
String,
super::ReviewNoteBlock,
crate::app::review::ReviewCommentOverlay,
)>,
> = std::collections::HashMap::new();
let mut review_preview_after_idx: std::collections::HashMap<
usize,
Vec<(
String,
super::ReviewNoteBlock,
crate::app::review::ReviewCommentOverlay,
)>,
> = std::collections::HashMap::new();
if app.review_mode()
&& matches!(
app.view_mode,
crate::app::ViewMode::UnifiedPane | crate::app::ViewMode::Blame
)
{
for overlay in app.review_comment_overlays_for_current_file() {
let block = review_note_block(app, &overlay, visible_width);
if overlay.is_hunk {
review_preview_before_idx
.entry(overlay.display_idx)
.or_default()
.push((overlay.anchor_key.clone(), block, overlay));
} else {
review_preview_after_idx
.entry(overlay.display_idx)
.or_default()
.push((overlay.anchor_key.clone(), block, overlay));
}
}
}
let trailing_review = review_preview_before_idx
.keys()
.chain(review_preview_after_idx.keys())
.copied()
.max()
.and_then(|idx| {
let rows = review_preview_before_idx
.get(&idx)
.into_iter()
.chain(review_preview_after_idx.get(&idx))
.flatten()
.map(|(_, block, _)| block.lines.len())
.sum::<usize>();
let remaining = view_lines.len().saturating_sub(idx.saturating_add(1));
(rows > 0 && remaining <= rows).then_some((idx, rows))
});
let trailing_review_idx = trailing_review.map(|(idx, _)| idx);
let trailing_review_rows = trailing_review.map(|(_, rows)| rows).unwrap_or(0);
let source_scroll_offset = if app.line_wrap {
scroll_offset
} else {
scroll_offset.min(trailing_review_idx.unwrap_or_else(|| view_lines.len().saturating_sub(1)))
};
let leading_rows_to_skip = scroll_offset.saturating_sub(source_scroll_offset);
let scroll_offset = source_scroll_offset;
let primary_marker = app.primary_marker.clone();
let extent_marker = app.extent_marker.clone();
let extent_marker_deleted = app.extent_marker_deleted.clone();
let debug_target = app.syntax_scope_target(view_lines);
let mut bg_lines: Option<Vec<Line<'static>>> =
if app.line_wrap && (app.diff_bg || app.fold_context.is_enabled()) {
Some(Vec::new())
} else {
None
};
let mut gutter_lines: Vec<Line> = Vec::new();
let mut content_lines: Vec<Line> = Vec::new();
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 display_len = if app.line_wrap {
0
} else {
app.render_total_lines(view_lines.len())
};
let mut primary_display_idx: Option<usize> = None;
let mut active_display_idx: Option<usize> = None;
let mut review_preview_rows: Vec<super::ReviewPreviewRow> = Vec::new();
let mut review_avatar_rows = Vec::new();
let mut fold_context_rows = Vec::new();
let mut visible_fold_index = 0;
let trailing_spacer_rows = if trailing_review_rows > 0 {
TRAILING_REVIEW_SPACER_ROWS
} else {
0
};
if !app.line_wrap {
let trailing_rows = trailing_review_rows
.saturating_add(trailing_spacer_rows)
.saturating_add(
view_lines
.len()
.checked_sub(1)
.and_then(|last| blame_extra_rows.and_then(|rows| rows.get(last)))
.copied()
.unwrap_or(0),
);
display_len = display_len.saturating_add(trailing_rows);
}
let (preview_mode, preview_hunk) = {
let state = app.multi_diff.current_navigator().state();
(state.hunk_preview_mode, state.current_hunk)
};
let pending_insert_only = if app.stepping {
app.pending_insert_only_in_current_hunk()
} else {
0
};
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 cursor_in_target = view_lines
.iter()
.any(|line| line.is_primary_active && line.hunk_index == Some(preview_hunk));
let cursor_visible = if app.line_wrap {
app.cursor_visible_in_wrap(visible_height)
} else {
view_lines
.iter()
.position(|line| line.is_primary_active)
.map(|idx| idx >= scroll_offset && idx < scroll_offset.saturating_add(visible_height))
.unwrap_or(false)
};
let (overflow_above, overflow_below) = if virtual_text.is_some() {
if app.line_wrap {
hunk_overflow_wrapped_unified(
view_lines,
preview_hunk,
wrap_width,
scroll_offset,
visible_height,
)
.unwrap_or((false, false))
} else {
hunk_overflow_unified(
view_lines,
preview_hunk,
scroll_offset,
visible_height,
app.view_mode,
)
.unwrap_or((false, false))
}
} else {
(false, false)
};
let mut next_visible_hunk: Vec<Option<usize>> = vec![None; view_lines.len()];
let mut next_hunk: Option<usize> = None;
for (idx, line) in view_lines.iter().enumerate().rev() {
next_visible_hunk[idx] = next_hunk;
if line.hunk_index.is_some() {
next_hunk = line.hunk_index;
}
}
let mut prev_visible_hunk_map: Vec<Option<usize>> = vec![None; view_lines.len()];
let mut prev_hunk: Option<usize> = None;
for (idx, line) in view_lines.iter().enumerate() {
prev_visible_hunk_map[idx] = prev_hunk;
if line.hunk_index.is_some() {
prev_hunk = line.hunk_index;
}
}
let cursor_idx = view_lines
.iter()
.position(|line| line.is_primary_active && line.hunk_index == Some(preview_hunk));
let cursor_at_first = cursor_idx
.map(|idx| prev_visible_hunk_map[idx] != Some(preview_hunk))
.unwrap_or(false);
let cursor_at_last = cursor_idx
.map(|idx| next_visible_hunk[idx] != Some(preview_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 mut prev_visible_hunk: Option<usize> = None;
let mut virtual_inserted = false;
let mut rendered_last_view_line = false;
for (idx, view_line) in view_lines.iter().enumerate() {
if !app.line_wrap && idx < scroll_offset {
continue;
}
if !app.line_wrap && gutter_lines.len() >= visible_height {
break;
}
if idx + 1 == view_lines.len() {
rendered_last_view_line = true;
}
let extra_rows = blame_extra_rows
.as_ref()
.and_then(|rows| rows.get(idx).copied())
.unwrap_or(0);
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[idx] != line_hunk;
if let Some(text) = virtual_text.as_ref() {
if !virtual_inserted
&& !prefer_cursor
&& force_top
&& line_hunk == Some(preview_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);
}
if let Some(bg_lines) = bg_lines.as_mut() {
super::push_wrapped_bg_line(bg_lines, wrap_width, virtual_wrap, None);
}
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(&idx) {
for (anchor_key, block, overlay) in previews {
let row_idx = if app.line_wrap {
display_len
} else {
content_lines.len()
};
let mut row_span = 0usize;
for note_line in &block.lines {
let mut virtual_spans = expand_tabs_in_spans(¬e_line.spans, TAB_WIDTH);
if app.line_wrap {
virtual_spans = super::fit_review_note_footer(virtual_spans, wrap_width);
}
let virtual_width = spans_width(&virtual_spans);
max_line_width = max_line_width.max(virtual_width);
let virtual_wrap = if app.line_wrap {
super::review_note_wrap_count(&virtual_spans, wrap_width)
} else {
1
};
row_span = row_span.saturating_add(virtual_wrap);
if app.line_wrap {
display_len += virtual_wrap;
}
if let Some(bg_lines) = bg_lines.as_mut() {
super::push_wrapped_bg_line(bg_lines, wrap_width, virtual_wrap, None);
}
content_lines.push(Line::from(virtual_spans));
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(avatar) = &block.avatar {
review_avatar_rows
.push((row_idx.saturating_add(avatar.row_offset), avatar.clone()));
}
review_preview_rows.push(super::review_preview_row(
row_idx,
row_span,
anchor_key.clone(),
overlay,
));
}
}
let fold_line = is_fold_line(view_line);
let fold_bg = fold_line.then(|| super::fold_context_background(app));
let fold_row = if app.line_wrap {
display_len
} else {
content_lines.len()
};
let viewport_start = if app.line_wrap { scroll_offset } else { 0 };
let fold_action_idx = (app.fold_context.is_enabled()
&& fold_line
&& fold_row >= viewport_start
&& fold_row < viewport_start.saturating_add(visible_height))
.then(|| {
let idx = visible_fold_index;
visible_fold_index += 1;
idx
});
let fold_band = super::fold_context_band(app, view_line, visible_width, fold_action_idx);
let line_num = view_line.old_line.or(view_line.new_line).unwrap_or(0);
let line_num_str = if fold_line || line_num == 0 {
" ".to_string()
} else {
format!("{:4}", line_num)
};
let insert_base = app.theme.insert_base();
let delete_base = app.theme.delete_base();
let modify_base = color::gradient_color(&app.theme.modify, 0.5);
let line_num_style = match view_line.kind {
LineKind::Context | LineKind::Unloaded => {
Style::default().fg(app.theme.diff_line_number)
}
LineKind::Inserted | LineKind::PendingInsert => Style::default().fg(insert_base),
LineKind::Deleted | LineKind::PendingDelete => Style::default().fg(delete_base),
LineKind::Modified | LineKind::PendingModify => {
Style::default().fg(Color::Rgb(modify_base.r, modify_base.g, modify_base.b))
}
};
let line_bg_gutter = fold_bg.or_else(|| {
app.diff_bg
.then(|| diff_line_bg(view_line.kind, &app.theme))
.flatten()
});
let (mut line_prefix, mut sign_style) = match view_line.kind {
LineKind::Context | LineKind::Unloaded => {
(" ", Style::default().fg(app.theme.diff_line_number))
}
LineKind::Inserted | LineKind::PendingInsert => {
if view_line.is_active {
(
"+",
super::insert_style(
app.animation_phase,
app.animation_progress,
app.is_backward_animation(),
app.theme.insert_base(),
app.theme.diff_context,
None,
),
)
} else {
("+", Style::default().fg(app.theme.insert_base()))
}
}
LineKind::Deleted | LineKind::PendingDelete => {
if view_line.is_active {
(
"-",
super::delete_style(
app.animation_phase,
app.animation_progress,
app.is_backward_animation(),
false,
app.theme.delete_base(),
app.theme.diff_context,
None,
),
)
} else {
("-", Style::default().fg(app.theme.delete_base()))
}
}
LineKind::Modified | LineKind::PendingModify => {
if view_line.is_active {
(
"~",
super::modify_style(
app.animation_phase,
app.animation_progress,
app.is_backward_animation(),
app.theme.modify_base(),
app.theme.diff_context,
None,
),
)
} else {
("~", Style::default().fg(app.theme.modify_base()))
}
}
};
if !app.gutter_signs {
line_prefix = " ";
sign_style = Style::default();
}
let show_extent = super::show_extent_marker(app, view_line);
let (active_marker, active_style) = if view_line.is_primary_active {
(
primary_marker.as_str(),
Style::default()
.fg(app.theme.primary)
.add_modifier(Modifier::BOLD),
)
} else if show_extent {
(
super::extent_marker_text(
extent_marker.as_str(),
extent_marker_deleted.as_str(),
view_line,
),
super::extent_marker_style(
app,
view_line.kind,
view_line.has_changes,
view_line.old_line,
view_line.new_line,
),
)
} else {
(" ", Style::default())
};
let mut gutter_spans = vec![
Span::styled(active_marker.to_string(), active_style),
Span::styled(line_num_str, line_num_style),
Span::styled(" ", Style::default()),
Span::styled(line_prefix, sign_style),
Span::styled(" ", Style::default()),
];
if let Some(bg) = line_bg_gutter {
gutter_spans = gutter_spans
.into_iter()
.enumerate()
.map(|(idx, span)| {
if idx == 0 && !fold_line {
span
} else {
Span::styled(span.content, span.style.bg(bg))
}
})
.collect();
}
gutter_lines.push(Line::from(gutter_spans));
let mut content_spans = fold_band
.as_ref()
.map(|band| band.spans.clone())
.unwrap_or_default();
let highlight_allowed =
matches!(view_line.kind, LineKind::Modified | LineKind::PendingModify)
|| !view_line.is_active
|| (view_line.is_active
&& !matches!(app.diff_highlight, DiffHighlightMode::None)
&& (!app.diff_bg || app.diff_fg == DiffForegroundMode::Theme));
let mut used_syntax = fold_band.is_some();
let mut used_inline_modified = false;
let mut peek_spans: Vec<ViewSpan> = Vec::new();
let mut has_peek = false;
let peek_mode = app.peek_mode_for_line(view_line);
if peek_mode == Some(crate::app::PeekMode::Old)
&& matches!(view_line.kind, LineKind::Modified | LineKind::PendingModify)
{
if let Some(change) = app
.multi_diff
.current_navigator()
.diff()
.changes
.get(view_line.change_id)
{
for span in &change.spans {
match span.kind {
ChangeKind::Equal => peek_spans.push(ViewSpan {
text: span.text.clone(),
kind: ViewSpanKind::Equal,
}),
ChangeKind::Delete | ChangeKind::Replace => {
peek_spans.push(ViewSpan {
text: span.text.clone(),
kind: ViewSpanKind::Deleted,
});
}
ChangeKind::Insert => {}
}
}
}
if !peek_spans.is_empty() {
has_peek = true;
}
}
let wants_diff_syntax = app.diff_fg == DiffForegroundMode::Syntax && app.syntax_enabled();
let in_preview_hunk =
preview_mode && view_line.hunk_index == Some(preview_hunk) && wants_diff_syntax;
if !used_inline_modified
&& in_preview_hunk
&& !has_peek
&& matches!(view_line.kind, LineKind::Modified | LineKind::PendingModify)
{
let change = {
let nav = app.multi_diff.current_navigator();
nav.diff().changes.get(view_line.change_id).cloned()
};
if let Some(change) = change {
if let Some(spans) = build_inline_modified_spans(&change, app, true, true) {
content_spans = spans;
used_inline_modified = true;
}
}
}
let pure_context = matches!(view_line.kind, LineKind::Context)
&& !view_line.has_changes
&& !view_line.is_active_change
&& view_line
.spans
.iter()
.all(|span| matches!(span.kind, ViewSpanKind::Equal));
let can_use_diff_syntax = wants_diff_syntax
&& !has_peek
&& !matches!(view_line.kind, LineKind::Modified | LineKind::PendingModify);
let (line_display_start, line_display_end) = if app.line_wrap {
let text = super::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 {
(idx, idx)
};
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
};
if !used_syntax
&& !used_inline_modified
&& app.syntax_enabled()
&& !view_line.is_active_change
&& in_syntax_window
&& (pure_context || can_use_diff_syntax || in_preview_hunk)
{
let use_old = view_line.kind == LineKind::Context && view_line.has_changes;
let side = if use_old {
SyntaxSide::Old
} else if view_line.new_line.is_some() {
SyntaxSide::New
} else {
SyntaxSide::Old
};
let line_num = if use_old {
view_line.old_line
} else {
view_line.new_line.or(view_line.old_line)
};
if in_warmup_window {
if let Some(line_num) = line_num {
app.record_syntax_warmup_line(side, line_num);
}
}
if let Some(spans) = app.cached_syntax_spans_for_line(side, line_num) {
content_spans = spans;
used_syntax = true;
}
}
if !used_syntax
&& app.stepping
&& view_line.is_active
&& !has_peek
&& matches!(view_line.kind, LineKind::Modified | LineKind::PendingModify)
{
let peek_override = app.is_peek_override_for_line(view_line);
let is_modified_peek =
peek_override && peek_mode == Some(crate::app::PeekMode::Modified);
let default_modified_only =
app.unified_modified_step_mode == ModifiedStepMode::Modified;
let change = {
let nav = app.multi_diff.current_navigator();
nav.diff().changes.get(view_line.change_id).cloned()
};
if let Some(change) = change {
let use_modified_only = if peek_override {
is_modified_peek
} else {
default_modified_only
};
if use_modified_only {
let use_animation = !is_modified_peek;
if let Some(spans) = build_modified_only_spans(&change, app, use_animation) {
content_spans = spans;
used_inline_modified = true;
}
} else if let Some(spans) = build_inline_modified_spans(&change, app, true, true) {
content_spans = spans;
used_inline_modified = true;
}
}
}
if !used_syntax && !used_inline_modified {
let mut rebuilt_spans: Vec<ViewSpan> = Vec::new();
let spans = if has_peek {
&peek_spans
} else if !app.stepping
&& matches!(view_line.kind, LineKind::Modified | LineKind::PendingModify)
{
if let Some(change) = app
.multi_diff
.current_navigator()
.diff()
.changes
.get(view_line.change_id)
{
for span in &change.spans {
match span.kind {
ChangeKind::Equal => rebuilt_spans.push(ViewSpan {
text: span.text.clone(),
kind: ViewSpanKind::Equal,
}),
ChangeKind::Delete => rebuilt_spans.push(ViewSpan {
text: span.text.clone(),
kind: ViewSpanKind::Deleted,
}),
ChangeKind::Insert => rebuilt_spans.push(ViewSpan {
text: span.text.clone(),
kind: ViewSpanKind::Inserted,
}),
ChangeKind::Replace => {
rebuilt_spans.push(ViewSpan {
text: span.text.clone(),
kind: ViewSpanKind::Deleted,
});
rebuilt_spans.push(ViewSpan {
text: span
.new_text
.clone()
.unwrap_or_else(|| span.text.clone()),
kind: ViewSpanKind::Inserted,
});
}
}
}
}
if rebuilt_spans.is_empty() {
&view_line.spans
} else {
&rebuilt_spans
}
} else {
&view_line.spans
};
let is_modified_line =
matches!(view_line.kind, LineKind::Modified | LineKind::PendingModify);
let treat_as_context = has_peek || (!app.stepping && is_modified_line);
let style_line_kind = if treat_as_context {
LineKind::Context
} else {
view_line.kind
};
let mut previous_style = None;
for view_span in spans {
let mut style = match previous_style {
Some((kind, style)) if kind == view_span.kind => style,
_ => {
let style = get_span_style(
view_span.kind,
style_line_kind,
view_line.is_active,
app,
highlight_allowed,
);
previous_style = Some((view_span.kind, style));
style
}
};
if !app.stepping && is_modified_line && app.diff_bg {
match view_span.kind {
ViewSpanKind::Deleted | ViewSpanKind::PendingDelete
if style.bg.is_none() =>
{
if let Some(bg) = app.theme.diff_removed_bg {
style = style.bg(bg);
}
}
ViewSpanKind::Inserted | ViewSpanKind::PendingInsert
if style.bg.is_none() =>
{
if let Some(bg) = app.theme.diff_added_bg {
style = style.bg(bg);
}
}
_ => {}
}
}
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);
append_styled_text(&mut content_spans, &text[..leading_ws_len], ws_style);
append_styled_text(&mut content_spans, trimmed, style);
} else {
append_styled_text(&mut content_spans, &view_span.text, style);
}
} else {
append_styled_text(&mut content_spans, &view_span.text, style);
}
}
}
let line_bg_line = fold_bg.or_else(|| {
app.diff_bg
.then(|| diff_line_bg(view_line.kind, &app.theme))
.flatten()
});
if let Some(bg) = line_bg_line {
content_spans = apply_line_bg(content_spans, bg, visible_width, app.line_wrap);
}
if highlight_allowed
&& !app.diff_bg
&& matches!(
app.diff_highlight,
DiffHighlightMode::Text | DiffHighlightMode::Word
)
&& (used_syntax || app.diff_fg == DiffForegroundMode::Syntax)
{
if let Some(bg) = diff_line_bg(view_line.kind, &app.theme) {
content_spans = apply_spans_bg(content_spans, bg);
}
}
if highlight_allowed {
if !app.diff_bg {
if app.diff_highlight == DiffHighlightMode::Text {
if !view_line.is_active {
content_spans =
clear_leading_ws_bg(content_spans, Some(app.theme.diff_context));
}
} else if app.diff_highlight == DiffHighlightMode::Word {
content_spans = clear_leading_ws_bg(content_spans, None);
}
} else if app.diff_highlight == DiffHighlightMode::Word {
content_spans = super::replace_leading_ws_bg(content_spans, None, line_bg_line);
}
}
let mut italic_line = false;
if app.syntax_enabled() {
if used_syntax {
italic_line = super::line_is_italic(&content_spans);
} else if in_syntax_window {
let use_old = view_line.kind == LineKind::Context && view_line.has_changes;
let side = if use_old {
SyntaxSide::Old
} else if view_line.new_line.is_some() {
SyntaxSide::New
} else {
SyntaxSide::Old
};
let line_num = if use_old {
view_line.old_line
} else {
view_line.new_line.or(view_line.old_line)
};
if let Some(spans) = app.cached_syntax_spans_for_line(side, line_num) {
italic_line = super::line_is_italic(&spans);
}
}
}
let line_text = spans_to_text(&content_spans);
let is_active_match = app.is_active_search_match(idx);
content_spans = app.highlight_search_spans(content_spans, &line_text, is_active_match);
if italic_line {
content_spans = super::apply_italic_spans(content_spans);
}
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();
}
if app.line_wrap {
if view_line.is_primary_active && primary_display_idx.is_none() {
primary_display_idx = Some(display_len);
}
if view_line.is_active && active_display_idx.is_none() {
active_display_idx = Some(display_len);
}
}
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
};
let line_display_row = display_len;
let line_content_row = content_lines.len();
app.record_remote_render_row(
view_line,
crate::app::RemoteRowGeometry {
side: None,
source_scroll: if app.line_wrap {
line_display_row + app.view_window_start()
} else {
idx + app.view_window_start()
},
rendered_start: if app.line_wrap {
line_display_row
} else {
line_content_row
},
viewport_start: if app.line_wrap { scroll_offset } else { 0 },
wrap_count,
},
);
if app.line_wrap {
display_len += wrap_count;
}
let mut display_spans = content_spans;
if !app.line_wrap {
if !fold_line {
display_spans = slice_spans(&display_spans, app.horizontal_scroll, visible_width);
}
if app.diff_bg {
if let Some(bg) = diff_line_bg(view_line.kind, &app.theme) {
display_spans = pad_spans_bg(display_spans, bg, visible_width);
}
}
}
if let Some(bg_lines) = bg_lines.as_mut() {
super::push_wrapped_bg_line(bg_lines, wrap_width, wrap_count, line_bg_line);
}
content_lines.push(Line::from(display_spans));
for wrapped_row in 0..wrap_count {
let local_row = if app.line_wrap {
line_display_row
.saturating_add(wrapped_row)
.checked_sub(scroll_offset)
} else {
Some(line_content_row.saturating_add(wrapped_row))
};
if let Some(local_row) = local_row.filter(|row| *row < visible_height) {
app.add_review_unified_line_row(local_row, idx);
}
}
if let Some(band) = fold_band {
fold_context_rows.push(crate::app::FoldContextRenderRow {
row: fold_row,
key: band.key,
top_x: band.top_x,
top_width: band.top_width,
bottom_x: band.bottom_x,
bottom_width: band.bottom_width,
});
}
if app.line_wrap && wrap_count > 1 {
let (wrap_marker, wrap_style) = if show_extent {
(
super::extent_marker_text(
extent_marker.as_str(),
extent_marker_deleted.as_str(),
view_line,
),
super::extent_marker_style(
app,
view_line.kind,
view_line.has_changes,
view_line.old_line,
view_line.new_line,
),
)
} else {
(" ", Style::default())
};
for _ in 1..wrap_count {
if let Some(bg) = line_bg_gutter {
let pad = " ".repeat(GUTTER_WIDTH as usize - 1);
gutter_lines.push(Line::from(vec![
Span::styled(wrap_marker.to_string(), wrap_style),
Span::styled(pad, Style::default().bg(bg)),
]));
} else {
gutter_lines.push(Line::from(Span::styled(
wrap_marker.to_string(),
wrap_style,
)));
}
}
}
if extra_rows > 0 {
if app.line_wrap {
display_len += extra_rows;
}
if let Some(bg_lines) = bg_lines.as_mut() {
super::push_wrapped_bg_line(bg_lines, wrap_width, extra_rows, None);
}
for _ in 0..extra_rows {
if !app.line_wrap && gutter_lines.len() >= visible_height {
break;
}
content_lines.push(Line::from(Span::raw("")));
gutter_lines.push(Line::from(Span::raw(" ")));
}
}
if let Some(previews) = review_preview_after_idx.get(&idx) {
for (anchor_key, block, overlay) in previews {
let row_idx = if app.line_wrap {
display_len
} else {
content_lines.len()
};
let mut row_span = 0usize;
for note_line in &block.lines {
let mut virtual_spans = expand_tabs_in_spans(¬e_line.spans, TAB_WIDTH);
if app.line_wrap {
virtual_spans = super::fit_review_note_footer(virtual_spans, wrap_width);
}
let virtual_width = spans_width(&virtual_spans);
max_line_width = max_line_width.max(virtual_width);
let virtual_wrap = if app.line_wrap {
super::review_note_wrap_count(&virtual_spans, wrap_width)
} else {
1
};
row_span = row_span.saturating_add(virtual_wrap);
if app.line_wrap {
display_len += virtual_wrap;
}
if let Some(bg_lines) = bg_lines.as_mut() {
super::push_wrapped_bg_line(bg_lines, wrap_width, virtual_wrap, None);
}
content_lines.push(Line::from(virtual_spans));
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(avatar) = &block.avatar {
review_avatar_rows
.push((row_idx.saturating_add(avatar.row_offset), avatar.clone()));
}
review_preview_rows.push(super::review_preview_row(
row_idx,
row_span,
anchor_key.clone(),
overlay,
));
}
}
if let Some(text) = virtual_text.as_ref() {
if !virtual_inserted
&& prefer_cursor
&& line_hunk == Some(preview_hunk)
&& view_line.is_primary_active
{
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);
}
if let Some(bg_lines) = bg_lines.as_mut() {
super::push_wrapped_bg_line(bg_lines, wrap_width, virtual_wrap, None);
}
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(preview_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);
}
if let Some(bg_lines) = bg_lines.as_mut() {
super::push_wrapped_bg_line(bg_lines, wrap_width, virtual_wrap, None);
}
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);
}
if let Some(bg_lines) = bg_lines.as_mut() {
super::push_wrapped_bg_line(bg_lines, wrap_width, virtual_wrap, None);
}
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);
}
if let Some(bg_lines) = bg_lines.as_mut() {
super::push_wrapped_bg_line(bg_lines, wrap_width, virtual_wrap, None);
}
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((debug_idx, ref label)) = debug_target {
if debug_idx == 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(" ")));
if let Some(bg_lines) = bg_lines.as_mut() {
super::push_wrapped_bg_line(bg_lines, wrap_width, debug_wrap, None);
}
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(" ")));
}
}
}
}
}
}
if rendered_last_view_line {
for _ in 0..trailing_spacer_rows {
content_lines.push(Line::raw(""));
gutter_lines.push(Line::raw(""));
if let Some(lines) = bg_lines.as_mut() {
super::push_wrapped_bg_line(lines, wrap_width, 1, None);
}
}
if app.line_wrap {
display_len = display_len.saturating_add(trailing_spacer_rows);
}
}
if leading_rows_to_skip > 0 {
let skipped = leading_rows_to_skip.min(content_lines.len());
app.crop_review_unified_line_rows(skipped);
content_lines.drain(..skipped);
gutter_lines.drain(..skipped.min(gutter_lines.len()));
if let Some(lines) = bg_lines.as_mut() {
lines.drain(..skipped.min(lines.len()));
}
review_preview_rows.retain_mut(|row| {
let end = row.row_idx.saturating_add(row.row_span);
if end <= skipped {
return false;
}
if row.row_idx < skipped {
let removed = skipped - row.row_idx;
row.row_idx = 0;
row.row_span = row.row_span.saturating_sub(removed);
for hit in [
&mut row.actions.edit,
&mut row.actions.reply,
&mut row.actions.resolve,
&mut row.actions.delete,
&mut row.actions.overflow,
] {
*hit = hit.and_then(|(offset, x, width)| {
offset.checked_sub(removed).map(|offset| (offset, x, width))
});
}
} else {
row.row_idx -= skipped;
}
true
});
review_avatar_rows.retain_mut(|(row, _)| {
let Some(shifted) = row.checked_sub(skipped) else {
return false;
};
*row = shifted;
true
});
fold_context_rows.retain_mut(|row| {
let Some(shifted) = row.row.checked_sub(skipped) else {
return false;
};
row.row = shifted;
true
});
}
app.commit_syntax_warmup_frame();
UnifiedRenderModel {
key,
gutter_lines,
content_lines,
bg_lines,
display_len,
max_line_width,
primary_display_idx,
active_display_idx,
review_preview_rows,
review_avatar_rows,
fold_context_rows,
}
}
fn render_unified_pane_cached(frame: &mut Frame, app: &mut App, area: Rect) {
let (area, scrollbar_area) = super::reserve_diff_scrollbar_lane(app, area);
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() {
super::render_binary_empty_state(frame, app, area);
return;
}
if app.line_wrap {
app.handle_search_scroll_if_needed(visible_height, visible_width);
} else {
app.ensure_active_visible_if_needed(visible_height, visible_width);
}
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 debug_enabled = super::view_debug_enabled();
if debug_enabled {
crate::syntax::syntax_debug_reset();
}
let mut scroll_offset = app.render_scroll_offset();
let key = unified_render_key(
app,
animation_frame,
visible_height,
visible_width,
scroll_offset,
);
let rebuild = app
.unified_render_cache
.as_ref()
.map(|cache| cache.key != key)
.unwrap_or(true);
if rebuild {
let model = build_unified_render_model(
app,
key,
&view_lines,
visible_height,
visible_width,
scroll_offset,
None,
);
app.unified_render_cache = Some(model);
}
let mut model = match app.unified_render_cache.take() {
Some(model) => model,
None => return,
};
let scroll_to_render_end = app.scroll_to_render_end_pending();
if scroll_to_render_end {
app.scroll_offset = usize::MAX;
}
if app.line_wrap {
app.ensure_active_visible_if_needed_wrapped(
visible_height,
model.display_len,
model.primary_display_idx.or(model.active_display_idx),
);
}
let scroll_before = app.scroll_offset;
app.clamp_scroll(model.display_len, visible_height, app.allow_overscroll());
if app.scroll_offset != scroll_before {
let new_scroll_offset = app.render_scroll_offset();
if new_scroll_offset != scroll_offset {
scroll_offset = new_scroll_offset;
let key = unified_render_key(
app,
animation_frame,
visible_height,
visible_width,
scroll_offset,
);
model = build_unified_render_model(
app,
key,
&view_lines,
visible_height,
visible_width,
scroll_offset,
None,
);
}
}
let max_line_width = model.max_line_width;
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(),
"unified",
visible_height,
visible_width,
scroll_offset,
extra,
);
}
render_unified_model(frame, app, area, &model, scroll_offset);
super::render_diff_scrollbar(
frame,
app,
scrollbar_area,
model.display_len,
area.height as usize,
scroll_offset,
);
app.unified_render_cache = Some(model);
}
fn render_unified_model(
frame: &mut Frame,
app: &mut App,
area: Rect,
model: &UnifiedRenderModel,
scroll_offset: usize,
) {
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 bg_style = app.theme.background.map(|bg| Style::default().bg(bg));
let mut gutter_paragraph = if app.line_wrap {
Paragraph::new(model.gutter_lines.clone()).scroll((scroll_offset as u16, 0))
} else {
Paragraph::new(model.gutter_lines.clone())
};
if let Some(style) = bg_style {
gutter_paragraph = gutter_paragraph.style(style);
}
frame.render_widget(gutter_paragraph, gutter_area);
if model.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(model.content_lines.clone())
.wrap(Wrap { trim: false })
.scroll((scroll_offset as u16, 0))
} else {
Paragraph::new(model.content_lines.clone())
};
let has_bg_overlay = model.bg_lines.is_some();
if let Some(bg_lines) = model.bg_lines.clone() {
let mut bg_paragraph = Paragraph::new(bg_lines).scroll((scroll_offset as u16, 0));
if let Some(style) = bg_style {
bg_paragraph = bg_paragraph.style(style);
}
frame.render_widget(bg_paragraph, content_area);
}
if !has_bg_overlay {
if let Some(style) = bg_style {
content_paragraph = content_paragraph.style(style);
}
}
frame.render_widget(content_paragraph, content_area);
let viewport_start = if app.line_wrap { scroll_offset } else { 0 };
app.add_fold_context_render_rows(content_area, &model.fold_context_rows, viewport_start);
if app.review_mode()
&& matches!(
app.view_mode,
crate::app::ViewMode::UnifiedPane | crate::app::ViewMode::Blame
)
{
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, avatar) in &model.review_avatar_rows {
if *row_idx < viewport_start || *row_idx >= viewport_end {
continue;
}
render_review_note_avatar(
frame,
app,
content_area,
row_idx.saturating_sub(viewport_start),
avatar,
);
}
for row in &model.review_preview_rows {
let start = row.row_idx;
let end = start.saturating_add(row.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_comment_preview_box(
content_area.x,
content_area.y.saturating_add(local_row as u16),
content_area.width,
height,
row.id,
row.anchor_key.clone(),
);
if let Some((offset, x, width)) = row.actions.edit {
let action_row = start.saturating_add(offset);
if action_row >= viewport_start && action_row < viewport_end {
app.add_review_preview_edit_box(
content_area.x.saturating_add(row.indent).saturating_add(x),
content_area
.y
.saturating_add((action_row - viewport_start) as u16),
width,
1,
row.id,
row.anchor_key.clone(),
);
}
}
if let Some((offset, x, width)) = row.actions.reply {
let action_row = start.saturating_add(offset);
if action_row >= viewport_start && action_row < viewport_end {
app.add_review_preview_reply_box(
content_area.x.saturating_add(row.indent).saturating_add(x),
content_area
.y
.saturating_add((action_row - viewport_start) as u16),
width,
1,
row.id,
row.anchor_key.clone(),
);
}
}
if let Some((offset, x, width)) = row.actions.resolve {
let action_row = start.saturating_add(offset);
if action_row >= viewport_start && action_row < viewport_end {
app.add_review_preview_resolve_box(
content_area.x.saturating_add(row.indent).saturating_add(x),
content_area
.y
.saturating_add((action_row - viewport_start) as u16),
width,
1,
row.id,
row.anchor_key.clone(),
);
}
}
if let Some((offset, x, width)) = row.actions.delete {
let action_row = start.saturating_add(offset);
if action_row >= viewport_start && action_row < viewport_end {
app.add_review_preview_delete_box(
content_area.x.saturating_add(row.indent).saturating_add(x),
content_area
.y
.saturating_add((action_row - viewport_start) as u16),
width,
1,
row.id,
row.anchor_key.clone(),
);
}
}
if let Some((offset, x, width)) = row.actions.overflow {
let action_row = start.saturating_add(offset);
if action_row >= viewport_start && action_row < viewport_end {
app.add_review_preview_overflow_box(
content_area.x.saturating_add(row.indent).saturating_add(x),
content_area
.y
.saturating_add((action_row - viewport_start) as u16),
width,
1,
row.id,
row.anchor_key.clone(),
);
}
}
}
}
}
}
pub fn render_unified_pane(frame: &mut Frame, app: &mut App, area: Rect) {
if matches!(app.view_mode, crate::app::ViewMode::UnifiedPane) {
render_unified_pane_cached(frame, app, area);
return;
}
render_unified_pane_uncached(frame, app, area);
}
fn render_unified_pane_uncached(frame: &mut Frame, app: &mut App, area: Rect) {
let (area, scrollbar_area) = super::reserve_diff_scrollbar_lane(app, area);
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() {
super::render_binary_empty_state(frame, app, area);
return;
}
if app.line_wrap {
app.handle_search_scroll_if_needed(visible_height, visible_width);
} else {
app.ensure_active_visible_if_needed(visible_height, visible_width);
}
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 debug_enabled = super::view_debug_enabled();
if debug_enabled {
crate::syntax::syntax_debug_reset();
}
let mut scroll_offset = app.render_scroll_offset();
let blame_extra_rows = if matches!(app.view_mode, crate::app::ViewMode::Blame) {
app.blame_extra_rows.clone()
} else {
None
};
let key = unified_render_key(
app,
animation_frame,
visible_height,
visible_width,
scroll_offset,
);
let mut model = build_unified_render_model(
app,
key,
&view_lines,
visible_height,
visible_width,
scroll_offset,
blame_extra_rows.as_deref(),
);
let scroll_to_render_end = app.scroll_to_render_end_pending();
if scroll_to_render_end {
app.scroll_offset = usize::MAX;
}
if app.line_wrap {
app.ensure_active_visible_if_needed_wrapped(
visible_height,
model.display_len,
model.primary_display_idx.or(model.active_display_idx),
);
}
let scroll_before = app.scroll_offset;
app.clamp_scroll(model.display_len, visible_height, app.allow_overscroll());
if app.scroll_offset != scroll_before {
let new_scroll_offset = app.render_scroll_offset();
if new_scroll_offset != scroll_offset {
scroll_offset = new_scroll_offset;
let key = unified_render_key(
app,
animation_frame,
visible_height,
visible_width,
scroll_offset,
);
model = build_unified_render_model(
app,
key,
&view_lines,
visible_height,
visible_width,
scroll_offset,
blame_extra_rows.as_deref(),
);
}
}
app.clamp_horizontal_scroll(model.max_line_width, visible_width);
app.set_current_max_line_width(model.max_line_width);
if debug_enabled {
let extra = super::syntax_debug_extra();
super::maybe_log_view_debug(
app,
view_lines.as_ref(),
"unified",
visible_height,
visible_width,
scroll_offset,
extra,
);
}
render_unified_model(frame, app, area, &model, scroll_offset);
super::render_diff_scrollbar(
frame,
app,
scrollbar_area,
model.display_len,
area.height as usize,
scroll_offset,
);
}
fn append_styled_text(spans: &mut Vec<Span<'static>>, text: &str, style: Style) {
if let Some(last) = spans.last_mut().filter(|span| {
span.style == style
&& span
.content
.as_bytes()
.last()
.is_some_and(|byte| matches!(byte, b' '..=b'~'))
&& text
.as_bytes()
.first()
.is_some_and(|byte| matches!(byte, b' '..=b'~'))
}) {
last.content.to_mut().push_str(text);
} else {
spans.push(Span::styled(text.to_owned(), style));
}
}
fn get_span_style(
kind: ViewSpanKind,
line_kind: LineKind,
is_active: bool,
app: &App,
highlight_allowed: bool,
) -> Style {
let backward = app.is_backward_animation();
let theme = &app.theme;
let is_modification = matches!(line_kind, LineKind::Modified | LineKind::PendingModify);
let highlight_bg = highlight_allowed
&& matches!(
app.diff_highlight,
DiffHighlightMode::Text | DiffHighlightMode::Word
);
let modified_bg = if highlight_bg {
super::boost_inline_bg(app, theme.diff_modified_bg, theme.modify_base())
} else {
None
};
let added_bg = if highlight_bg {
super::boost_inline_bg(app, theme.diff_added_bg, theme.insert_base())
} else {
None
};
let removed_bg = if highlight_bg {
super::boost_inline_bg(app, theme.diff_removed_bg, theme.delete_base())
} else {
None
};
match kind {
ViewSpanKind::Equal => Style::default().fg(theme.diff_context),
ViewSpanKind::Inserted => {
if is_modification {
if is_active {
return super::modify_style(
app.animation_phase,
app.animation_progress,
backward,
theme.modify_base(),
theme.diff_context,
modified_bg,
);
}
let mut style = Style::default().fg(theme.modify_base());
if let Some(bg) = modified_bg {
style = style.bg(bg);
}
return style;
}
if is_active {
super::insert_style(
app.animation_phase,
app.animation_progress,
backward,
theme.insert_base(),
theme.diff_context,
added_bg,
)
} else {
super::insert_style(
crate::app::AnimationPhase::Idle,
1.0,
false,
theme.insert_base(),
theme.diff_context,
added_bg,
)
}
}
ViewSpanKind::Deleted => {
if is_modification {
if is_active {
return super::modify_style(
app.animation_phase,
app.animation_progress,
backward,
theme.modify_base(),
theme.diff_context,
modified_bg,
);
}
let mut style = Style::default().fg(theme.modify_base());
if let Some(bg) = modified_bg {
style = style.bg(bg);
}
return style;
}
if is_active {
super::delete_style(
app.animation_phase,
app.animation_progress,
backward,
app.strikethrough_deletions,
theme.delete_base(),
theme.diff_context,
removed_bg,
)
} else {
super::delete_style(
crate::app::AnimationPhase::Idle,
1.0,
false,
app.strikethrough_deletions,
theme.delete_base(),
theme.diff_context,
removed_bg,
)
}
}
ViewSpanKind::PendingInsert => {
if is_modification {
if is_active {
return super::modify_style(
app.animation_phase,
app.animation_progress,
backward,
theme.modify_base(),
theme.diff_context,
modified_bg,
);
}
let mut style = Style::default().fg(theme.modify_dim());
if let Some(bg) = modified_bg {
style = style.bg(bg);
}
return style;
}
if is_active {
super::insert_style(
app.animation_phase,
app.animation_progress,
backward,
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_modification {
if is_active {
return super::modify_style(
app.animation_phase,
app.animation_progress,
backward,
theme.modify_base(),
theme.diff_context,
modified_bg,
);
}
let mut style = Style::default().fg(theme.modify_dim());
if let Some(bg) = modified_bg {
style = style.bg(bg);
}
return style;
}
if is_active {
super::delete_style(
app.animation_phase,
app.animation_progress,
backward,
app.strikethrough_deletions,
theme.delete_base(),
theme.diff_context,
removed_bg,
)
} else {
let mut style = Style::default().fg(theme.delete_dim());
if let Some(bg) = removed_bg {
style = style.bg(bg);
}
style
}
}
}
}
#[cfg(test)]
mod tests {
use super::unified_render_key;
use crate::app::{App, ViewMode};
use oyo_core::{AnimationFrame, MultiFileDiff};
#[test]
fn search_navigation_invalidates_the_unified_render_cache() {
let diff = MultiFileDiff::from_file_pair(
"README.md".into(),
"README.md".into(),
"old\n".into(),
"use one\nuse two\n".into(),
);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.goto_last_step();
app.start_search();
for ch in "use".chars() {
app.push_search_char(ch);
}
app.search_next();
let first_target = app.search_target();
let first = unified_render_key(&mut app, AnimationFrame::Idle, 20, 80, 0);
app.search_next();
let second = unified_render_key(&mut app, AnimationFrame::Idle, 20, 80, 0);
assert_ne!(app.search_target(), first_target);
assert_ne!(second.search_revision, first.search_revision);
assert_ne!(second, first);
}
}
#[cfg(test)]
mod span_batch_tests {
use super::*;
use ratatui::buffer::Buffer;
use ratatui::widgets::Widget;
#[test]
fn batching_preserves_cells_styles_and_grapheme_boundaries() {
let style = Style::default().fg(Color::Red).bg(Color::Black);
let pieces = [
"ab", "cd", " ", "e", "\u{301}", " ", "👩", "\u{200d}", "💻", "ä¸", "\t", "Z",
];
let original = pieces
.iter()
.map(|text| Span::styled(*text, style))
.collect::<Vec<_>>();
let mut batched = Vec::new();
for text in pieces {
append_styled_text(&mut batched, text, style);
}
assert!(batched.len() < original.len());
assert_eq!(spans_width(&batched), spans_width(&original));
let original = expand_tabs_in_spans(&original, TAB_WIDTH);
let batched = expand_tabs_in_spans(&batched, TAB_WIDTH);
for width in [4, 12, 30] {
let area = Rect::new(0, 0, width, 4);
let mut expected = Buffer::empty(area);
let mut actual = Buffer::empty(area);
Paragraph::new(Line::from(original.clone()))
.wrap(Wrap { trim: false })
.render(area, &mut expected);
Paragraph::new(Line::from(batched.clone()))
.wrap(Wrap { trim: false })
.render(area, &mut actual);
assert_eq!(actual, expected);
}
let mut mixed = Vec::new();
append_styled_text(&mut mixed, " ", style);
append_styled_text(
&mut mixed,
"deleted",
style.add_modifier(Modifier::CROSSED_OUT),
);
append_styled_text(&mut mixed, " ", style);
assert_eq!(mixed.len(), 3);
assert!(!mixed[0].style.add_modifier.contains(Modifier::CROSSED_OUT));
assert!(mixed[1].style.add_modifier.contains(Modifier::CROSSED_OUT));
}
}