use crate::tui::app_states::source::{
JsonTreeNode, JsonValueType, SourceChangeStatus, SourcePanelState, SourceSortMode,
SourceViewMode,
};
use crate::tui::theme::colors;
use ratatui::{
buffer::Buffer,
prelude::*,
widgets::{Block, Borders, Scrollbar, ScrollbarOrientation, ScrollbarState},
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
#[derive(Debug, Clone)]
pub struct FlatJsonItem {
pub node_id: String,
pub depth: usize,
pub display_key: String,
pub value_preview: String,
pub value_type: Option<JsonValueType>,
pub is_expandable: bool,
pub is_expanded: bool,
pub child_count_label: String,
pub preview: String,
pub is_last_sibling: bool,
pub ancestors_last: Vec<bool>,
}
#[allow(clippy::too_many_arguments)]
pub fn flatten_json_tree(
node: &JsonTreeNode,
parent_path: &str,
depth: usize,
expanded: &std::collections::HashSet<String>,
items: &mut Vec<FlatJsonItem>,
is_last_sibling: bool,
ancestors_last: &[bool],
sort_mode: SourceSortMode,
parent_key: &str,
) {
let node_id = node.node_id(parent_path);
let is_expanded = expanded.contains(&node_id);
let value_preview = match node {
JsonTreeNode::Leaf { value, .. } => value.clone(),
_ => String::new(),
};
let value_type = match node {
JsonTreeNode::Leaf { value_type, .. } => Some(*value_type),
_ => None,
};
let preview = if node.is_expandable() {
node.preview_label(parent_key)
} else {
String::new()
};
items.push(FlatJsonItem {
node_id: node_id.clone(),
depth,
display_key: node.display_key(),
value_preview,
value_type,
is_expandable: node.is_expandable(),
is_expanded,
child_count_label: node.child_count_label(),
preview,
is_last_sibling,
ancestors_last: ancestors_last.to_vec(),
});
if is_expanded && let Some(children) = node.children() {
let mut current_ancestors = ancestors_last.to_vec();
current_ancestors.push(is_last_sibling);
let this_key = node.key();
let child_parent_key = if this_key.is_empty() {
parent_key
} else {
this_key
};
let sorted_children: Vec<&JsonTreeNode>;
let children_ref: &[&JsonTreeNode] = match sort_mode {
SourceSortMode::None => {
sorted_children = children.iter().collect();
&sorted_children
}
SourceSortMode::KeyAsc => {
sorted_children = {
let mut v: Vec<&JsonTreeNode> = children.iter().collect();
v.sort_by_key(|a| a.display_key());
v
};
&sorted_children
}
SourceSortMode::KeyDesc => {
sorted_children = {
let mut v: Vec<&JsonTreeNode> = children.iter().collect();
v.sort_by_key(|b| std::cmp::Reverse(b.display_key()));
v
};
&sorted_children
}
};
for (i, child) in children_ref.iter().enumerate() {
let child_is_last = i == children_ref.len() - 1;
flatten_json_tree(
child,
&node_id,
depth + 1,
expanded,
items,
child_is_last,
¤t_ancestors,
sort_mode,
child_parent_key,
);
}
}
}
pub fn render_source_panel(
frame: &mut Frame,
area: Rect,
state: &mut SourcePanelState,
title: &str,
is_focused: bool,
) {
match state.view_mode {
SourceViewMode::Tree => render_source_tree(frame, area, state, title, is_focused),
SourceViewMode::Raw => render_source_raw(frame, area, state, title, is_focused),
}
}
fn render_source_tree(
frame: &mut Frame,
area: Rect,
state: &mut SourcePanelState,
title: &str,
is_focused: bool,
) {
let scheme = colors();
let border_color = if is_focused {
scheme.border_focused
} else {
scheme.border
};
let has_tree = state.json_tree.is_some() || state.xml_tree.is_some();
let mode_hint = if has_tree { " 'v':Raw " } else { "" };
let node_info = if state.total_node_count > 0 {
format!(" ({} nodes)", state.total_node_count)
} else {
String::new()
};
let filter_label = state.filter_label();
let sort_label = state.sort_mode.label();
let indicators = format!(
"{}{}",
if filter_label.is_empty() {
String::new()
} else {
format!(" {filter_label}")
},
if sort_label.is_empty() {
String::new()
} else {
format!(" {sort_label}")
},
);
let preliminary_block = Block::default().borders(Borders::ALL);
let preliminary_inner = preliminary_block.inner(area);
drop(preliminary_block);
state.ensure_flat_cache();
let pre_item_count = state.cached_flat_items.len();
let percent = if pre_item_count > 0 {
(state.selected + 1) * 100 / pre_item_count
} else {
0
};
let status_bar = format!(
" Ln {}/{} ({}%) ",
state.selected + 1,
pre_item_count,
percent
);
let block = Block::default()
.title(format!(" {title} [Tree]{node_info}{indicators}{mode_hint}"))
.title_style(Style::default().fg(border_color).bold())
.title_bottom(
Line::from(status_bar)
.right_aligned()
.style(Style::default().fg(scheme.text_muted)),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let inner = block.inner(area);
frame.render_widget(block, area);
let _ = preliminary_inner;
if inner.width < 4 || inner.height < 1 {
return;
}
if state.json_tree.is_none() && state.xml_tree.is_none() {
let msg = ratatui::widgets::Paragraph::new(
"Content is not valid JSON or XML. Press 'v' for raw text view.",
)
.style(Style::default().fg(scheme.text_muted));
frame.render_widget(msg, inner);
return;
}
let item_count = state.cached_flat_items.len();
let (summary_area, inner) = if !state.change_annotations.is_empty() && inner.height > 5 {
(
Some(Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1,
}),
Rect {
x: inner.x,
y: inner.y + 1,
width: inner.width,
height: inner.height.saturating_sub(1),
},
)
} else {
(None, inner)
};
if let Some(sa) = summary_area {
if state.change_indices.is_empty() && !state.change_annotations.is_empty() {
state.build_change_indices();
}
render_change_summary_bar(frame, sa, state, item_count, &scheme);
}
let inner = if inner.height > 3 {
let breadcrumb = state
.cached_flat_items
.get(state.selected)
.map_or_else(String::new, |selected_item| {
breadcrumb_from_node_id(&selected_item.node_id)
});
if !breadcrumb.is_empty() {
let bc_style = Style::default().fg(scheme.text_muted).italic();
let bc_width = inner.width as usize;
let bc_display = if UnicodeWidthStr::width(breadcrumb.as_str()) > bc_width {
let target = bc_width.saturating_sub(3);
let mut width = 0;
let trimmed: String = breadcrumb
.chars()
.rev()
.take_while(|ch| {
let w = UnicodeWidthChar::width(*ch).unwrap_or(1);
if width + w > target {
return false;
}
width += w;
true
})
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("...{trimmed}")
} else {
breadcrumb
};
render_str(
frame.buffer_mut(),
inner.x,
inner.y,
&bc_display,
inner.width,
bc_style,
);
}
Rect {
x: inner.x,
y: inner.y + 1,
width: inner.width,
height: inner.height - 1,
}
} else {
inner
};
let visible_height = inner.height as usize;
for (i, item) in state
.cached_flat_items
.iter()
.skip(state.scroll_offset)
.take(visible_height)
.enumerate()
{
let y = inner.y + i as u16;
let abs_idx = state.scroll_offset + i;
let is_selected = abs_idx == state.selected;
if item.node_id.starts_with("__collapsed_") {
let indent = " ";
let display = format!("{indent}{}", item.display_key);
let style = if is_selected {
Style::default()
.fg(scheme.muted)
.italic()
.add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(scheme.muted).italic()
};
render_str(frame.buffer_mut(), inner.x, y, &display, inner.width, style);
continue;
}
if item.node_id.starts_with("__gap_") {
let style = Style::default()
.fg(scheme.muted)
.add_modifier(Modifier::DIM);
let indent = " ";
let display = format!("{indent}{}", item.display_key);
render_str(frame.buffer_mut(), inner.x, y, &display, inner.width, style);
continue;
}
let mut x = inner.x;
if !state.change_annotations.is_empty() {
let change_status = state.find_annotation(&item.node_id);
let (ch, color) = match change_status {
Some(SourceChangeStatus::Added) => ("+", scheme.added),
Some(SourceChangeStatus::Removed) => ("-", scheme.removed),
Some(SourceChangeStatus::Modified) => ("~", scheme.modified),
None => (" ", scheme.muted),
};
let span = Span::styled(ch, Style::default().fg(color).bold());
frame.buffer_mut().set_span(x, y, &span, 1);
x += 1;
}
if state.show_line_numbers {
let total = state.cached_flat_items.len();
let gutter_w = format!("{total}").len();
let num_str = format!("{:>gutter_w$} ", abs_idx + 1);
let line_num_style = if !state.change_annotations.is_empty() {
match state.find_annotation(&item.node_id) {
Some(SourceChangeStatus::Added) => Style::default().fg(scheme.added).bold(),
Some(SourceChangeStatus::Removed) => Style::default().fg(scheme.removed).bold(),
Some(SourceChangeStatus::Modified) => {
Style::default().fg(scheme.modified).bold()
}
None => Style::default().fg(scheme.muted),
}
} else {
Style::default().fg(scheme.text_muted)
};
render_str(
frame.buffer_mut(),
x,
y,
&num_str,
inner.width,
line_num_style,
);
x += num_str.len() as u16;
}
if state.bookmarks.contains(&abs_idx) {
render_str(
frame.buffer_mut(),
x,
y,
"\u{2605} ",
inner.width.saturating_sub(x - inner.x),
Style::default().fg(scheme.warning),
);
}
let compact = state.compact_mode;
let (sel_str, sel_width): (&str, u16) = if compact {
if is_selected { (">", 1) } else { (" ", 1) }
} else if is_selected {
("> ", 2)
} else {
(" ", 2)
};
render_str(
frame.buffer_mut(),
x,
y,
sel_str,
inner.width.saturating_sub(x - inner.x),
Style::default().fg(scheme.accent).bold(),
);
x += sel_width;
if item.depth > 0 {
let connector_style = Style::default().fg(scheme.muted);
let connector_width: u16 = if compact { 2 } else { 3 };
for d in 0..item.depth - 1 {
let is_ancestor_last = item.ancestors_last.get(d + 1).copied().unwrap_or(false);
let connector = if compact {
if is_ancestor_last { " " } else { "│ " }
} else if is_ancestor_last {
" "
} else {
"│ "
};
render_str(
frame.buffer_mut(),
x,
y,
connector,
inner.width.saturating_sub(x - inner.x),
connector_style,
);
x += connector_width;
}
let branch = if compact {
if item.is_last_sibling { "└ " } else { "├ " }
} else if item.is_last_sibling {
"└─ "
} else {
"├─ "
};
render_str(
frame.buffer_mut(),
x,
y,
branch,
inner.width.saturating_sub(x - inner.x),
connector_style,
);
x += connector_width;
}
if item.is_expandable {
let (indicator, ind_width): (&str, u16) = if compact {
if item.is_expanded {
("\u{25bc}", 1)
} else {
("\u{25b6}", 1)
}
} else if item.is_expanded {
("\u{25bc} ", 2)
} else {
("\u{25b6} ", 2)
};
render_str(
frame.buffer_mut(),
x,
y,
indicator,
inner.width.saturating_sub(x - inner.x),
Style::default().fg(scheme.accent),
);
x += ind_width;
}
let remaining = inner.x + inner.width;
if !item.display_key.is_empty() && x < remaining {
let key_style = Style::default().fg(scheme.primary);
render_str(
frame.buffer_mut(),
x,
y,
&item.display_key,
remaining - x,
key_style,
);
let key_width = UnicodeWidthStr::width(item.display_key.as_str());
let max_w = (remaining - x) as usize;
x += key_width.min(max_w) as u16;
if (!item.value_preview.is_empty() || item.is_expandable) && x + 2 < remaining {
render_str(
frame.buffer_mut(),
x,
y,
": ",
remaining - x,
Style::default().fg(scheme.text_muted),
);
x += 2;
}
}
if x < remaining {
let max_w = (remaining - x) as usize;
if item.is_expandable
&& !item.is_expanded
&& !item.preview.is_empty()
&& state.version_diffs.contains_key(&item.node_id)
{
let (old_v, new_v) = &state.version_diffs[&item.node_id];
let preview = &item.preview;
let (name_part, type_suffix) = if let Some(at_pos) = preview.find('@') {
let name = &preview[..at_pos];
let rest = &preview[at_pos + 1..];
let suffix = rest.find(" (").map_or("", |p| &rest[p..]);
(name, suffix)
} else {
(preview.as_str(), "")
};
let buf = frame.buffer_mut();
render_str(
buf,
x,
y,
name_part,
remaining - x,
Style::default().fg(scheme.text),
);
x += (UnicodeWidthStr::width(name_part).min(max_w)) as u16;
if x < remaining {
render_str(buf, x, y, " ", remaining - x, Style::default());
x += 1;
}
if x < remaining {
render_str(
buf,
x,
y,
old_v,
remaining - x,
Style::default()
.fg(scheme.muted)
.add_modifier(Modifier::DIM),
);
x += (UnicodeWidthStr::width(old_v.as_str()).min((remaining - x) as usize))
as u16;
}
if x + 3 < remaining {
render_str(
buf,
x,
y,
" \u{2192} ",
remaining - x,
Style::default().fg(scheme.modified),
);
x += 3;
}
if x < remaining {
render_str(
buf,
x,
y,
new_v,
remaining - x,
Style::default().fg(scheme.modified).bold(),
);
x += (UnicodeWidthStr::width(new_v.as_str()).min((remaining - x) as usize))
as u16;
}
if !type_suffix.is_empty() && x < remaining {
render_str(
buf,
x,
y,
type_suffix,
remaining - x,
Style::default().fg(scheme.muted),
);
}
} else if item.is_expandable && !item.is_expanded && !item.preview.is_empty() {
let preview_str = crate::tui::widgets::truncate_str(&item.preview, max_w);
render_str(
frame.buffer_mut(),
x,
y,
&preview_str,
remaining - x,
Style::default().fg(scheme.text),
);
} else if item.is_expandable {
render_str(
frame.buffer_mut(),
x,
y,
&item.child_count_label,
remaining - x,
Style::default().fg(scheme.text_muted),
);
} else if !item.value_preview.is_empty() {
let val_style = match item.value_type {
Some(JsonValueType::String) => Style::default().fg(scheme.success),
Some(JsonValueType::Number) => Style::default().fg(scheme.accent),
Some(JsonValueType::Boolean) => Style::default().fg(scheme.warning),
Some(JsonValueType::Null) => Style::default().fg(scheme.text_muted),
None => Style::default().fg(scheme.text),
};
let display_val = crate::tui::widgets::truncate_str(&item.value_preview, max_w);
render_str(
frame.buffer_mut(),
x,
y,
&display_val,
remaining - x,
val_style,
);
let val_w = UnicodeWidthStr::width(display_val.as_str());
x += val_w.min(max_w) as u16;
}
}
if let Some(label) = state.link_labels.get(&abs_idx) {
let short_label = shorten_path_label(label, 40);
let link_text = format!(" \u{2192} {short_label}");
if x + 4 < remaining {
let avail = (remaining - x) as usize;
let truncated = crate::tui::widgets::truncate_str(&link_text, avail);
render_str(
frame.buffer_mut(),
x,
y,
&truncated,
remaining - x,
Style::default().fg(scheme.primary).italic(),
);
}
}
if !state.change_annotations.is_empty()
&& let Some(status) = state.find_annotation(&item.node_id)
{
let fg = match status {
SourceChangeStatus::Added => scheme.added,
SourceChangeStatus::Removed => scheme.removed,
SourceChangeStatus::Modified => scheme.modified,
};
for col in inner.x..remaining {
if let Some(cell) = frame.buffer_mut().cell_mut((col, y)) {
cell.set_fg(fg);
cell.modifier.insert(ratatui::style::Modifier::BOLD);
}
}
}
if is_selected {
for col in inner.x..remaining {
if let Some(cell) = frame.buffer_mut().cell_mut((col, y)) {
cell.set_bg(scheme.selection);
}
}
}
if !state.search_matches.is_empty()
&& state.search_query.len() >= 2
&& state.search_matches.binary_search(&abs_idx).is_ok()
{
let is_current = state.search_matches.get(state.search_current) == Some(&abs_idx);
let display_text = format!(
"{}: {}{}",
item.display_key, item.value_preview, item.child_count_label
);
highlight_search_in_row(
frame.buffer_mut(),
y,
inner.x,
remaining,
&display_text,
&state.search_query,
is_current,
&scheme,
);
}
}
render_search_bar(frame, inner, state, &scheme);
if item_count > visible_height {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.thumb_style(Style::default().fg(scheme.accent))
.track_style(Style::default().fg(scheme.muted));
let mut sb_state = ScrollbarState::new(item_count).position(state.selected);
frame.render_stateful_widget(scrollbar, inner, &mut sb_state);
}
}
fn render_source_raw(
frame: &mut Frame,
area: Rect,
state: &mut SourcePanelState,
title: &str,
is_focused: bool,
) {
let scheme = colors();
let border_color = if is_focused {
scheme.border_focused
} else {
scheme.border
};
let has_json_tree = state.json_tree.is_some();
let has_xml_tree = state.xml_tree.is_some();
let has_tree = has_json_tree || has_xml_tree;
let mode_hint = if has_tree { " 'v':Tree " } else { "" };
let wrap_hint = if state.word_wrap { " [Wrap]" } else { "" };
let col_hint = if !state.word_wrap && state.h_scroll_offset > 0 {
format!(" col:{}", state.h_scroll_offset)
} else {
String::new()
};
let fold_hint = if !state.folded_lines.is_empty() {
format!(" [{} folded]", state.folded_lines.len())
} else {
String::new()
};
let total_lines = state.raw_lines.len();
let percent = if total_lines > 0 {
(state.selected + 1) * 100 / total_lines
} else {
0
};
let col_info = if !state.word_wrap && state.h_scroll_offset > 0 {
format!(" Col {}", state.h_scroll_offset)
} else {
String::new()
};
let status_bar = format!(
" Ln {}/{}{} ({}%) ",
state.selected + 1,
total_lines,
col_info,
percent
);
let block = Block::default()
.title(format!(
" {title} [Raw] ({total_lines} lines){col_hint}{wrap_hint}{fold_hint}{mode_hint} ",
))
.title_style(Style::default().fg(border_color).bold())
.title_bottom(
Line::from(status_bar)
.right_aligned()
.style(Style::default().fg(scheme.text_muted)),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width < 4 || inner.height < 1 {
return;
}
let (summary_area, inner) = if !state.change_annotations.is_empty() && inner.height > 5 {
(
Some(Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1,
}),
Rect {
x: inner.x,
y: inner.y + 1,
width: inner.width,
height: inner.height.saturating_sub(1),
},
)
} else {
(None, inner)
};
if let Some(sa) = summary_area {
if state.change_indices.is_empty() && !state.change_annotations.is_empty() {
state.build_change_indices();
}
render_change_summary_bar(frame, sa, state, total_lines, &scheme);
}
let inner = if inner.height > 3 && has_json_tree {
let breadcrumb = state
.raw_line_node_ids
.get(state.selected)
.filter(|nid| !nid.is_empty())
.map_or_else(String::new, |nid| breadcrumb_from_node_id(nid));
if !breadcrumb.is_empty() {
let bc_style = Style::default().fg(scheme.text_muted).italic();
let bc_width = inner.width as usize;
let bc_display = if UnicodeWidthStr::width(breadcrumb.as_str()) > bc_width {
let target = bc_width.saturating_sub(3);
let mut width = 0;
let trimmed: String = breadcrumb
.chars()
.rev()
.take_while(|ch| {
let w = UnicodeWidthChar::width(*ch).unwrap_or(1);
if width + w > target {
return false;
}
width += w;
true
})
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("...{trimmed}")
} else {
breadcrumb
};
render_str(
frame.buffer_mut(),
inner.x,
inner.y,
&bc_display,
inner.width,
bc_style,
);
Rect {
x: inner.x,
y: inner.y + 1,
width: inner.width,
height: inner.height - 1,
}
} else {
inner
}
} else {
inner
};
let visible_height = inner.height as usize;
let visible_lines = build_visible_lines(state, visible_height);
let gutter_width = if state.raw_lines.is_empty() {
1
} else {
format!("{}", state.raw_lines.len()).len()
};
let remaining = inner.x + inner.width;
let match_line = state.matching_bracket(state.selected);
let enclosing_scope = find_enclosing_scope(state, state.selected);
let scope_indent_level: Option<usize> = enclosing_scope.and_then(|(scope_start, _)| {
state.raw_lines.get(scope_start).map(|line| {
let leading = line.len() - line.trim_start().len();
leading / 2 })
});
let sticky_header_line: Option<usize> = enclosing_scope.and_then(|(scope_start, _)| {
if !visible_lines.is_empty() && scope_start < visible_lines[0] {
Some(scope_start)
} else {
None
}
});
for (i, &abs_idx) in visible_lines.iter().enumerate() {
let y = inner.y + i as u16;
if y >= inner.y + inner.height {
break;
}
let line_num = abs_idx + 1;
let is_selected = abs_idx == state.selected;
let is_bracket_match = match_line == Some(abs_idx);
let line = &state.raw_lines[abs_idx];
let is_folded_start = state.folded_lines.contains(&abs_idx);
let is_structural = is_structural_line(line);
let change_gutter_offset: u16 = if !state.change_annotations.is_empty() {
let change_status = state
.raw_line_node_ids
.get(abs_idx)
.and_then(|node_id| state.find_annotation(node_id));
let (ch, color) = match change_status {
Some(SourceChangeStatus::Added) => ("+", scheme.added),
Some(SourceChangeStatus::Removed) => ("-", scheme.removed),
Some(SourceChangeStatus::Modified) => ("~", scheme.modified),
None => (" ", scheme.muted),
};
let span = Span::styled(ch, Style::default().fg(color).bold());
frame.buffer_mut().set_span(inner.x, y, &span, 1);
1
} else {
0
};
if state.bookmarks.contains(&abs_idx) {
render_str(
frame.buffer_mut(),
inner.x + change_gutter_offset,
y,
"\u{2605}",
1,
Style::default().fg(scheme.warning),
);
}
let raw_change_status = state
.raw_line_node_ids
.get(abs_idx)
.and_then(|node_id| state.find_annotation(node_id));
let gutter_style = if is_bracket_match {
Style::default().fg(scheme.accent).bold()
} else if !state.change_annotations.is_empty() {
match raw_change_status {
Some(SourceChangeStatus::Added) => Style::default().fg(scheme.added).bold(),
Some(SourceChangeStatus::Removed) => Style::default().fg(scheme.removed).bold(),
Some(SourceChangeStatus::Modified) => Style::default().fg(scheme.modified).bold(),
None => Style::default().fg(scheme.muted),
}
} else if is_structural {
Style::default().fg(scheme.muted)
} else {
Style::default().fg(scheme.text_muted)
};
let fold_char = if is_folded_start {
"\u{25b6}" } else if state.bracket_pairs.contains_key(&abs_idx) {
"\u{25bc}" } else {
" "
};
let num_str = format!("{fold_char}{line_num:>gutter_width$} \u{2502} ");
render_str(
frame.buffer_mut(),
inner.x + change_gutter_offset,
y,
&num_str,
remaining - inner.x,
gutter_style,
);
if state.bracket_pairs.contains_key(&abs_idx)
|| state.bracket_pairs_reverse.contains_key(&abs_idx)
{
let fold_style = if is_folded_start {
Style::default().fg(scheme.accent)
} else {
Style::default().fg(scheme.muted)
};
if let Some(cell) = frame
.buffer_mut()
.cell_mut((inner.x + change_gutter_offset, y))
{
cell.set_style(fold_style);
}
}
let content_x = inner.x + change_gutter_offset + num_str.len() as u16;
if content_x < remaining {
let max_w = remaining - content_x;
if is_folded_start {
let fold_end = state
.bracket_pairs
.get(&abs_idx)
.copied()
.unwrap_or(abs_idx);
let hidden = fold_end - abs_idx;
let trimmed = line.trim();
let summary = format!("{trimmed} \u{2026} ({hidden} lines)");
render_str(
frame.buffer_mut(),
content_x,
y,
&summary,
max_w,
Style::default().fg(scheme.accent),
);
} else {
let display_line = if state.word_wrap {
line.to_string()
} else if state.h_scroll_offset > 0 {
skip_display_chars(line, state.h_scroll_offset)
} else {
line.to_string()
};
if has_json_tree {
if is_structural {
render_str(
frame.buffer_mut(),
content_x,
y,
&display_line,
max_w,
Style::default().fg(scheme.muted),
);
} else {
render_json_line_highlighted(
frame.buffer_mut(),
content_x,
y,
&display_line,
max_w,
&scheme,
);
}
} else if has_xml_tree {
render_xml_line_highlighted(
frame.buffer_mut(),
content_x,
y,
&display_line,
max_w,
&scheme,
);
} else {
render_str(
frame.buffer_mut(),
content_x,
y,
&display_line,
max_w,
Style::default().fg(scheme.text),
);
}
if state.show_indent_guides
&& has_json_tree
&& state.h_scroll_offset == 0
&& !is_structural
&& let Some(scope_level) = scope_indent_level
{
let leading_spaces = line.len() - line.trim_start().len();
let indent_size = 2; let line_depth = leading_spaces / indent_size;
if line_depth >= scope_level {
let guide_offset = (scope_level * indent_size) as u16;
let gx = content_x + guide_offset;
if gx < remaining
&& let Some(cell) = frame.buffer_mut().cell_mut((gx, y))
{
cell.set_char('\u{2506}') .set_style(Style::default().fg(scheme.accent));
}
}
}
}
}
if let Some(label) = state.link_labels.get(&abs_idx)
&& !is_folded_start
&& !is_structural
{
let short_label = shorten_path_label(label, 40);
let link_text = format!(" \u{2192} {short_label}");
let line_display_len = if state.word_wrap || state.h_scroll_offset == 0 {
UnicodeWidthStr::width(line.as_str())
} else {
UnicodeWidthStr::width(skip_display_chars(line, state.h_scroll_offset).as_str())
};
let link_x = content_x + (line_display_len as u16).min(remaining - content_x);
if link_x + 4 < remaining {
let avail = (remaining - link_x) as usize;
let truncated = crate::tui::widgets::truncate_str(&link_text, avail);
render_str(
frame.buffer_mut(),
link_x,
y,
&truncated,
remaining - link_x,
Style::default().fg(scheme.primary).italic(),
);
}
}
if !state.change_annotations.is_empty() {
let annotation = state
.raw_line_node_ids
.get(abs_idx)
.and_then(|node_id| state.find_annotation(node_id));
if let Some(status) = annotation {
let fg = match status {
SourceChangeStatus::Added => scheme.added,
SourceChangeStatus::Removed => scheme.removed,
SourceChangeStatus::Modified => scheme.modified,
};
for col in inner.x..remaining {
if let Some(cell) = frame.buffer_mut().cell_mut((col, y)) {
cell.set_fg(fg);
cell.modifier.insert(ratatui::style::Modifier::BOLD);
}
}
}
}
if is_selected {
for col in inner.x..remaining {
if let Some(cell) = frame.buffer_mut().cell_mut((col, y)) {
cell.set_bg(scheme.selection);
}
}
}
if !state.search_matches.is_empty()
&& state.search_query.len() >= 2
&& state.search_matches.binary_search(&abs_idx).is_ok()
{
let is_current = state.search_matches.get(state.search_current) == Some(&abs_idx);
highlight_search_in_row(
frame.buffer_mut(),
y,
content_x,
remaining,
line,
&state.search_query,
is_current,
&scheme,
);
}
}
if let Some(header_line_idx) = sticky_header_line
&& let Some(header_line) = state.raw_lines.get(header_line_idx)
{
let header_y = inner.y;
for col in inner.x..remaining {
if let Some(cell) = frame.buffer_mut().cell_mut((col, header_y)) {
cell.reset();
cell.set_bg(scheme.background_alt);
}
}
let header_num = format!(" {:>gutter_width$} \u{2502} ", header_line_idx + 1,);
render_str(
frame.buffer_mut(),
inner.x,
header_y,
&header_num,
remaining - inner.x,
Style::default()
.fg(scheme.text_muted)
.bg(scheme.background_alt),
);
let header_content_x = inner.x + header_num.len() as u16;
if header_content_x < remaining {
let display_line = if state.h_scroll_offset > 0 {
skip_display_chars(header_line, state.h_scroll_offset)
} else {
header_line.to_string()
};
render_str(
frame.buffer_mut(),
header_content_x,
header_y,
&display_line,
remaining - header_content_x,
Style::default().fg(scheme.text).bg(scheme.background_alt),
);
}
}
render_search_bar(frame, inner, state, &scheme);
if state.raw_lines.len() > visible_height {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.thumb_style(Style::default().fg(scheme.accent))
.track_style(Style::default().fg(scheme.muted));
let mut sb_state = ScrollbarState::new(state.raw_lines.len()).position(state.selected);
frame.render_stateful_widget(scrollbar, inner, &mut sb_state);
}
}
fn find_enclosing_scope(state: &SourcePanelState, line: usize) -> Option<(usize, usize)> {
if let Some(&end) = state.bracket_pairs.get(&line) {
return Some((line, end));
}
for start_line in (0..line).rev() {
if let Some(&end_line) = state.bracket_pairs.get(&start_line)
&& end_line >= line
{
return Some((start_line, end_line));
}
}
None
}
fn shorten_path_label(label: &str, max_len: usize) -> String {
if label.len() <= max_len {
return label.to_string();
}
if let Some(last_sep) = label.rfind('/') {
let basename = &label[last_sep + 1..];
if basename.len() + 2 <= max_len {
return format!("\u{2026}/{basename}"); }
}
let truncated: String = label.chars().take(max_len.saturating_sub(1)).collect();
format!("{truncated}\u{2026}")
}
fn build_visible_lines(state: &mut SourcePanelState, visible_height: usize) -> Vec<usize> {
if state.folded_lines.is_empty() {
if visible_height > 0 {
if state.selected >= state.scroll_offset + visible_height {
state.scroll_offset = state.selected.saturating_sub(visible_height - 1);
} else if state.selected < state.scroll_offset {
state.scroll_offset = state.selected;
}
}
return (state.scroll_offset..)
.take(visible_height)
.take_while(|&i| i < state.raw_lines.len())
.collect();
}
let total = state.raw_lines.len();
let mut all_visible = Vec::with_capacity(total / 2);
let mut i = 0;
while i < total {
all_visible.push(i);
if state.folded_lines.contains(&i) {
if let Some(&end) = state.bracket_pairs.get(&i) {
i = end + 1;
continue;
}
}
i += 1;
}
let sel_pos = all_visible
.iter()
.position(|&l| l == state.selected)
.unwrap_or(0);
let start = sel_pos.saturating_sub(visible_height / 3);
let end = (start + visible_height).min(all_visible.len());
let start = if end == all_visible.len() {
end.saturating_sub(visible_height)
} else {
start
};
all_visible[start..end].to_vec()
}
fn is_structural_line(line: &str) -> bool {
let trimmed = line.trim();
matches!(trimmed, "{" | "}" | "}," | "[" | "]" | "]," | "{}" | "[]")
}
fn render_json_line_highlighted(
buf: &mut Buffer,
x: u16,
y: u16,
line: &str,
max_width: u16,
scheme: &crate::tui::theme::ColorScheme,
) {
let mut cx = x;
let limit = x + max_width;
let chars: Vec<char> = line.chars().collect();
let mut i = 0;
while i < chars.len() && cx < limit {
let ch = chars[i];
match ch {
'{' | '}' | '[' | ']' | ':' | ',' => {
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char(ch)
.set_style(Style::default().fg(scheme.text_muted));
}
cx += 1;
i += 1;
}
'"' => {
let start = i;
i += 1;
while i < chars.len() && chars[i] != '"' {
if chars[i] == '\\' {
i += 1;
}
i += 1;
}
if i < chars.len() {
i += 1; }
let mut lookahead = i;
while lookahead < chars.len() && chars[lookahead].is_whitespace() {
lookahead += 1;
}
let is_key = lookahead < chars.len() && chars[lookahead] == ':';
let style = if is_key {
Style::default().fg(scheme.primary)
} else {
Style::default().fg(scheme.success)
};
for ch in &chars[start..i] {
if cx >= limit {
break;
}
let w = UnicodeWidthChar::width(*ch).unwrap_or(1) as u16;
if cx + w > limit {
break;
}
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char(*ch).set_style(style);
}
cx += w;
}
}
't' | 'f' if json_looks_like_bool(&chars, i) => {
let word_len = if ch == 't' { 4 } else { 5 };
let style = Style::default().fg(scheme.warning);
for j in 0..word_len {
if i + j < chars.len() && cx < limit {
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char(chars[i + j]).set_style(style);
}
cx += 1;
}
}
i += word_len;
}
'n' if json_looks_like_null(&chars, i) => {
let style = Style::default().fg(scheme.text_muted);
for j in 0..4 {
if i + j < chars.len() && cx < limit {
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char(chars[i + j]).set_style(style);
}
cx += 1;
}
}
i += 4;
}
'0'..='9' | '-' => {
let style = Style::default().fg(scheme.accent);
while i < chars.len()
&& (chars[i].is_ascii_digit()
|| chars[i] == '.'
|| chars[i] == '-'
|| chars[i] == 'e'
|| chars[i] == 'E'
|| chars[i] == '+')
{
if cx >= limit {
break;
}
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char(chars[i]).set_style(style);
}
cx += 1;
i += 1;
}
}
_ => {
let w = UnicodeWidthChar::width(ch).unwrap_or(1) as u16;
if cx + w <= limit {
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char(ch)
.set_style(Style::default().fg(scheme.text));
}
cx += w;
}
i += 1;
}
}
}
}
fn json_looks_like_bool(chars: &[char], i: usize) -> bool {
let remaining = &chars[i..];
(remaining.len() >= 4 && remaining[..4] == ['t', 'r', 'u', 'e'])
|| (remaining.len() >= 5 && remaining[..5] == ['f', 'a', 'l', 's', 'e'])
}
fn json_looks_like_null(chars: &[char], i: usize) -> bool {
let remaining = &chars[i..];
remaining.len() >= 4 && remaining[..4] == ['n', 'u', 'l', 'l']
}
fn render_xml_line_highlighted(
buf: &mut Buffer,
x: u16,
y: u16,
line: &str,
max_width: u16,
scheme: &crate::tui::theme::ColorScheme,
) {
let mut cx = x;
let limit = x + max_width;
let chars: Vec<char> = line.chars().collect();
let mut i = 0;
while i < chars.len() && cx < limit {
let ch = chars[i];
match ch {
'<' => {
if i + 3 < chars.len()
&& chars[i + 1] == '!'
&& chars[i + 2] == '-'
&& chars[i + 3] == '-'
{
let comment_style = Style::default().fg(scheme.text_muted);
while i < chars.len() && cx < limit {
let w = UnicodeWidthChar::width(chars[i]).unwrap_or(1) as u16;
if cx + w > limit {
break;
}
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char(chars[i]).set_style(comment_style);
}
cx += w;
if i >= 2 && chars[i] == '>' && chars[i - 1] == '-' && chars[i - 2] == '-' {
i += 1;
break;
}
i += 1;
}
continue;
}
let struct_style = Style::default().fg(scheme.text_muted);
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char('<').set_style(struct_style);
}
cx += 1;
i += 1;
if i < chars.len() && chars[i] == '/' {
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char('/').set_style(struct_style);
}
cx += 1;
i += 1;
}
let tag_style = Style::default().fg(scheme.primary);
while i < chars.len()
&& cx < limit
&& !chars[i].is_whitespace()
&& chars[i] != '>'
&& chars[i] != '/'
{
let w = UnicodeWidthChar::width(chars[i]).unwrap_or(1) as u16;
if cx + w > limit {
break;
}
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char(chars[i]).set_style(tag_style);
}
cx += w;
i += 1;
}
while i < chars.len() && cx < limit && chars[i] != '>' {
if chars[i] == '/' {
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char('/').set_style(struct_style);
}
cx += 1;
i += 1;
} else if chars[i] == '"' {
let val_style = Style::default().fg(scheme.success);
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char('"').set_style(val_style);
}
cx += 1;
i += 1;
while i < chars.len() && cx < limit && chars[i] != '"' {
let w = UnicodeWidthChar::width(chars[i]).unwrap_or(1) as u16;
if cx + w > limit {
break;
}
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char(chars[i]).set_style(val_style);
}
cx += w;
i += 1;
}
if i < chars.len() && cx < limit {
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char('"').set_style(val_style);
}
cx += 1;
i += 1;
}
} else if chars[i] == '=' {
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char('=').set_style(struct_style);
}
cx += 1;
i += 1;
} else if chars[i].is_whitespace() {
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char(chars[i])
.set_style(Style::default().fg(scheme.text));
}
cx += 1;
i += 1;
} else {
let attr_style = Style::default().fg(scheme.accent);
let w = UnicodeWidthChar::width(chars[i]).unwrap_or(1) as u16;
if cx + w <= limit {
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char(chars[i]).set_style(attr_style);
}
cx += w;
}
i += 1;
}
}
if i < chars.len() && cx < limit && chars[i] == '>' {
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char('>').set_style(struct_style);
}
cx += 1;
i += 1;
}
}
'&' => {
let entity_style = Style::default().fg(scheme.accent);
while i < chars.len() && cx < limit {
let w = UnicodeWidthChar::width(chars[i]).unwrap_or(1) as u16;
if cx + w > limit {
break;
}
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char(chars[i]).set_style(entity_style);
}
cx += w;
let done = chars[i] == ';';
i += 1;
if done {
break;
}
}
}
_ => {
let w = UnicodeWidthChar::width(ch).unwrap_or(1) as u16;
if cx + w <= limit {
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char(ch)
.set_style(Style::default().fg(scheme.text));
}
cx += w;
}
i += 1;
}
}
}
}
fn render_search_bar(
frame: &mut Frame,
inner: Rect,
state: &SourcePanelState,
scheme: &crate::tui::theme::ColorScheme,
) {
if !state.search_active && state.search_query.is_empty() {
return;
}
let search_y = inner.y + inner.height.saturating_sub(1);
let remaining = inner.x + inner.width;
for col in inner.x..remaining {
if let Some(cell) = frame.buffer_mut().cell_mut((col, search_y)) {
cell.reset();
}
}
let cursor = if state.search_active { "\u{2588}" } else { "" };
let match_info = if state.search_query.len() >= 2 {
if state.search_matches.is_empty() {
" (no matches)".to_string()
} else {
format!(
" ({}/{})",
state.search_current + 1,
state.search_matches.len()
)
}
} else {
String::new()
};
let regex_indicator = if state.search_regex_mode { "[R] " } else { "" };
let search_text = format!(
"/{regex_indicator}{}{}{}",
state.search_query, cursor, match_info
);
render_str(
frame.buffer_mut(),
inner.x,
search_y,
&search_text,
inner.width,
Style::default().fg(scheme.accent),
);
}
#[allow(clippy::too_many_arguments)]
fn highlight_search_in_row(
buf: &mut Buffer,
y: u16,
x_start: u16,
x_end: u16,
displayed_text: &str,
query: &str,
is_current_match: bool,
scheme: &crate::tui::theme::ColorScheme,
) {
if query.is_empty() || displayed_text.is_empty() {
return;
}
let bg = if is_current_match {
scheme.search_highlight_bg
} else {
scheme.highlight
};
let lower_text = displayed_text.to_lowercase();
let lower_query = query.to_lowercase();
let char_cols: Vec<(usize, u16)> = {
let mut cols = Vec::new();
let mut col: u16 = 0;
for (byte_idx, ch) in displayed_text.char_indices() {
cols.push((byte_idx, col));
col += UnicodeWidthChar::width(ch).unwrap_or(1) as u16;
}
cols
};
let mut search_start = 0;
while let Some(pos) = lower_text[search_start..].find(&lower_query) {
let byte_start = search_start + pos;
let byte_end = byte_start + lower_query.len();
search_start = byte_start + 1;
let col_start = char_cols
.iter()
.find(|(b, _)| *b >= byte_start)
.map(|(_, c)| *c);
let col_end = char_cols
.iter()
.find(|(b, _)| *b >= byte_end)
.map(|(_, c)| *c)
.unwrap_or_else(|| {
char_cols
.last()
.map(|(_, c)| {
*c + UnicodeWidthChar::width(displayed_text.chars().last().unwrap_or(' '))
.unwrap_or(1) as u16
})
.unwrap_or(0)
});
if let Some(start_col) = col_start {
for col in start_col..col_end {
let abs_col = x_start + col;
if abs_col >= x_end {
break;
}
if let Some(cell) = buf.cell_mut((abs_col, y)) {
cell.set_bg(bg);
}
}
}
}
}
fn breadcrumb_from_node_id(node_id: &str) -> String {
if node_id.is_empty() {
return String::new();
}
let parts: Vec<&str> = node_id.split('.').collect();
parts.join(" > ")
}
fn skip_display_chars(s: &str, skip_width: usize) -> String {
let mut skipped = 0;
let mut chars = s.chars();
for ch in chars.by_ref() {
let w = UnicodeWidthChar::width(ch).unwrap_or(1);
skipped += w;
if skipped >= skip_width {
break;
}
}
chars.collect()
}
fn render_change_summary_bar(
frame: &mut Frame,
area: Rect,
state: &SourcePanelState,
total_items: usize,
scheme: &crate::tui::theme::ColorScheme,
) {
if area.width < 12 || total_items == 0 {
return;
}
let change_count = state.change_indices.len();
let label = format!(" {change_count} changes");
let bar_width = (area.width as usize).saturating_sub(label.len() + 4);
if bar_width < 5 {
return;
}
let mut bar: Vec<(char, Color)> = vec![('\u{2591}', scheme.muted); bar_width];
for &idx in &state.change_indices {
let col = (idx * bar_width) / total_items.max(1);
let col = col.min(bar_width - 1);
let color = state
.change_status_at_index(idx)
.map_or(scheme.modified, |s| match s {
SourceChangeStatus::Added => scheme.added,
SourceChangeStatus::Removed => scheme.removed,
SourceChangeStatus::Modified => scheme.modified,
});
bar[col] = ('\u{2588}', color); }
let cursor_col = if total_items > 0 {
(state.selected * bar_width) / total_items.max(1)
} else {
0
};
let cursor_col = cursor_col.min(bar_width.saturating_sub(1));
let mut spans: Vec<Span> = Vec::with_capacity(bar_width + 6);
spans.push(Span::raw("["));
for (i, (ch, color)) in bar.iter().enumerate() {
if i == cursor_col {
spans.push(Span::styled(
"\u{25b4}",
Style::default().fg(scheme.accent).bold(),
)); } else {
spans.push(Span::styled(ch.to_string(), Style::default().fg(*color)));
}
}
spans.push(Span::raw("]"));
spans.push(Span::styled(label, Style::default().fg(scheme.muted)));
let line = Line::from(spans);
frame
.buffer_mut()
.set_line(area.x, area.y, &line, area.width);
}
pub fn render_str(buf: &mut Buffer, x: u16, y: u16, s: &str, max_width: u16, style: Style) {
let mut cx = x;
let limit = x + max_width;
for ch in s.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(1) as u16;
if cx + w > limit {
break;
}
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char(ch).set_style(style);
}
cx += w;
}
}