use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph, Wrap},
Frame,
};
use crate::app::{App, FormatMode, InputMode};
use crate::rendering::{RelfEntry, RelfLineStyle};
fn highlight_json_line(line: &str) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut chars = line.chars().peekable();
let mut current = String::new();
while let Some(ch) = chars.next() {
match ch {
'"' => {
if !current.is_empty() {
spans.push(Span::styled(
current.clone(),
Style::default().fg(Color::Gray),
));
current.clear();
}
let mut string_content = String::from("\"");
let mut escaped = false;
while let Some(next_ch) = chars.next() {
string_content.push(next_ch);
if next_ch == '\\' && !escaped {
escaped = true;
} else if next_ch == '"' && !escaped {
break;
} else {
escaped = false;
}
}
let mut temp_chars = chars.clone();
let mut is_key = false;
while let Some(peek_ch) = temp_chars.next() {
if peek_ch == ':' {
is_key = true;
break;
} else if !peek_ch.is_whitespace() {
break;
}
}
let color = if is_key {
Color::Rgb(156, 220, 254) } else {
Color::Rgb(206, 145, 120) };
spans.push(Span::styled(
string_content,
Style::default().fg(color),
));
}
'{' | '}' | '[' | ']' => {
if !current.is_empty() {
spans.push(Span::styled(
current.clone(),
Style::default().fg(Color::Gray),
));
current.clear();
}
spans.push(Span::styled(
ch.to_string(),
Style::default().fg(Color::Rgb(255, 217, 102)), ));
}
':' | ',' => {
if !current.is_empty() {
spans.push(Span::styled(
current.clone(),
Style::default().fg(Color::Gray),
));
current.clear();
}
spans.push(Span::styled(
ch.to_string(),
Style::default().fg(Color::White),
));
}
't' | 'f' | 'n' => {
let peek_str: String = std::iter::once(ch)
.chain(chars.clone().take(4))
.collect();
if peek_str.starts_with("true") || peek_str.starts_with("false") || peek_str.starts_with("null") {
if !current.is_empty() {
spans.push(Span::styled(
current.clone(),
Style::default().fg(Color::Gray),
));
current.clear();
}
let keyword = if peek_str.starts_with("true") {
chars.nth(2); "true"
} else if peek_str.starts_with("false") {
chars.nth(3); "false"
} else {
chars.nth(2); "null"
};
spans.push(Span::styled(
keyword.to_string(),
Style::default().fg(Color::Rgb(86, 156, 214)), ));
} else {
current.push(ch);
}
}
'0'..='9' | '-' => {
let mut num = String::from(ch);
while let Some(&next_ch) = chars.peek() {
if next_ch.is_ascii_digit() || next_ch == '.' || next_ch == 'e' || next_ch == 'E' || next_ch == '-' || next_ch == '+' {
num.push(chars.next().unwrap());
} else {
break;
}
}
if !current.is_empty() {
spans.push(Span::styled(
current.clone(),
Style::default().fg(Color::Gray),
));
current.clear();
}
spans.push(Span::styled(
num,
Style::default().fg(Color::Rgb(181, 206, 168)), ));
}
_ => {
current.push(ch);
}
}
}
if !current.is_empty() {
spans.push(Span::styled(
current,
Style::default().fg(Color::Gray),
));
}
if spans.is_empty() {
spans.push(Span::styled(
String::new(),
Style::default().fg(Color::Gray),
));
}
spans
}
pub fn ui(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(f.area());
if !app.editing_entry {
render_content(f, app, chunks[0]);
} else {
render_empty_content(f, app, chunks[0]);
}
render_status_bar(f, app, chunks[1]);
if app.editing_entry {
render_edit_overlay(f, app);
}
}
fn render_empty_content(f: &mut Frame, app: &App, area: Rect) {
let title = match &app.file_path {
Some(path) => format!(" {} ", path.display()),
None => String::new(),
};
let outer_block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::default().fg(Color::DarkGray));
let inner_area = outer_block.inner(area);
f.render_widget(outer_block, area);
let num_entries = app.relf_entries.len();
if num_entries == 0 {
return;
}
let selected = app.selected_entry_index;
let max_visible_cards = 5;
let scroll_start = if selected < max_visible_cards {
0
} else {
selected - max_visible_cards + 1
};
let visible_entries: Vec<(usize, &RelfEntry)> = app.relf_entries
.iter()
.enumerate()
.skip(scroll_start)
.take(max_visible_cards)
.collect();
if visible_entries.is_empty() {
return;
}
let constraints: Vec<Constraint> = visible_entries
.iter()
.map(|_| Constraint::Min(3))
.collect();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(inner_area);
for (i, (_entry_idx, entry)) in visible_entries.iter().enumerate() {
let filler = Block::default()
.style(Style::default().bg(entry.bg_color));
f.render_widget(filler, chunks[i]);
}
}
fn render_content(f: &mut Frame, app: &mut App, area: Rect) {
if app.format_mode == FormatMode::View && !app.relf_entries.is_empty() {
render_relf_cards(f, app, area);
return;
}
let inner_area = area.inner(Margin {
horizontal: 1,
vertical: 1,
});
app.content_width = inner_area.width;
if app.format_mode == FormatMode::View {
app.hscroll = 0;
}
app.visible_height = inner_area.height;
let visual_lines = app.build_visual_lines();
let lines_count = visual_lines.len() as u16;
let visible_height = inner_area.height;
let bottom_padding = 10u16; let padded_lines_count = lines_count + bottom_padding;
app.max_scroll = padded_lines_count.saturating_sub(visible_height);
let empty_line = String::new();
let visible_content: Vec<_> = visual_lines
.iter()
.skip(app.scroll as usize)
.chain(std::iter::repeat(&empty_line).take(bottom_padding as usize))
.take(visible_height as usize)
.collect();
let content_text = {
let w_cols = app.get_content_width() as usize;
let off_cols = if app.format_mode == FormatMode::View {
0
} else {
app.hscroll as usize
};
let mut lines_vec: Vec<Line> = Vec::new();
for (line_idx, s) in visible_content.iter().enumerate() {
let actual_idx = line_idx + app.scroll as usize;
let slice = app.slice_columns(s, off_cols, w_cols);
let mut spans: Vec<Span> = Vec::new();
let line_style = if app.format_mode == FormatMode::View {
app.relf_visual_styles.get(actual_idx)
} else {
None
};
if !app.search_query.is_empty() && app.format_mode == FormatMode::Edit {
let json_spans = highlight_json_line(&slice);
let query_lower = app.search_query.to_lowercase();
let line_lower = slice.to_lowercase();
let mut result_spans: Vec<Span> = Vec::new();
let mut char_pos = 0;
for json_span in json_spans {
let span_text = json_span.content.to_string();
let span_len = span_text.len();
let span_start = char_pos;
let span_end = char_pos + span_len;
let mut last_split = 0;
while let Some(match_pos) = line_lower[span_start..span_end].find(&query_lower) {
let abs_match_pos = span_start + match_pos;
if abs_match_pos < span_start + last_split {
break;
}
let rel_match_start = abs_match_pos - span_start;
let rel_match_end = (abs_match_pos + app.search_query.len()).min(span_end) - span_start;
let is_current_match = app
.current_match_index
.and_then(|idx| app.search_matches.get(idx))
.map(|(line, col)| *line == actual_idx && *col == abs_match_pos + off_cols)
.unwrap_or(false);
let bg_color = if is_current_match {
Color::Rgb(255, 255, 150) } else {
Color::Rgb(100, 180, 200) };
if rel_match_start > last_split {
result_spans.push(Span::styled(
span_text[last_split..rel_match_start].to_string(),
json_span.style,
));
}
result_spans.push(Span::styled(
span_text[rel_match_start..rel_match_end].to_string(),
json_span.style.bg(bg_color),
));
last_split = rel_match_end;
}
if last_split < span_len {
result_spans.push(Span::styled(
span_text[last_split..].to_string(),
json_span.style,
));
}
char_pos = span_end;
}
spans = result_spans;
} else if !app.search_query.is_empty() {
let query_lower = app.search_query.to_lowercase();
let line_lower = slice.to_lowercase();
let mut last_pos = 0;
while let Some(match_pos) = line_lower[last_pos..].find(&query_lower) {
let actual_pos = last_pos + match_pos;
if actual_pos > last_pos {
spans.push(Span::styled(
slice[last_pos..actual_pos].to_string(),
apply_relf_style(Style::default().fg(Color::Gray), line_style),
));
}
let is_current_match = app
.current_match_index
.and_then(|idx| app.search_matches.get(idx))
.map(|(line, col)| *line == actual_idx && *col == actual_pos + off_cols)
.unwrap_or(false);
let match_end = actual_pos + app.search_query.len();
let highlight_style = if is_current_match {
Style::default().fg(Color::Black).bg(Color::Yellow) } else {
Style::default().fg(Color::Black).bg(Color::Cyan) };
spans.push(Span::styled(
slice[actual_pos..match_end.min(slice.len())].to_string(),
highlight_style,
));
last_pos = match_end;
}
if last_pos < slice.len() {
spans.push(Span::styled(
slice[last_pos..].to_string(),
apply_relf_style(Style::default().fg(Color::Gray), line_style),
));
}
} else {
if app.format_mode == FormatMode::Edit {
spans = highlight_json_line(&slice);
} else {
spans.push(Span::styled(
slice.clone(),
apply_relf_style(Style::default().fg(Color::Gray), line_style),
));
}
}
if app.format_mode == FormatMode::Edit
&& (app.input_mode == InputMode::Insert || app.input_mode == InputMode::Normal)
&& app.show_cursor
{
if actual_idx == app.content_cursor_line {
let cursor_char_pos = app.content_cursor_col;
let prefix_cols = app.prefix_display_width(s, cursor_char_pos);
if prefix_cols >= off_cols && prefix_cols < off_cols + w_cols {
let insert_col_in_view = prefix_cols - off_cols;
let mut char_count = 0;
let mut cursor_inserted = false;
let mut new_spans: Vec<Span> = Vec::new();
for span in spans.iter() {
let span_text = span.content.to_string();
let span_chars: Vec<char> = span_text.chars().collect();
let span_len = span_chars.len();
if !cursor_inserted && char_count + span_len >= insert_col_in_view {
let pos_in_span = insert_col_in_view - char_count;
if pos_in_span == 0 {
new_spans.push(Span::styled("│".to_string(), span.style));
new_spans.push(span.clone());
} else if pos_in_span >= span_len {
new_spans.push(span.clone());
new_spans.push(Span::styled("│".to_string(), span.style));
} else {
let before: String = span_chars[..pos_in_span].iter().collect();
let after: String = span_chars[pos_in_span..].iter().collect();
new_spans.push(Span::styled(before, span.style));
new_spans.push(Span::styled("│".to_string(), span.style));
new_spans.push(Span::styled(after, span.style));
}
cursor_inserted = true;
} else {
new_spans.push(span.clone());
}
char_count += span_len;
}
if !cursor_inserted {
let last_style = spans.last().map(|s| s.style).unwrap_or_default();
new_spans.push(Span::styled("│".to_string(), last_style));
}
spans = new_spans;
}
}
}
if spans.is_empty() {
spans.push(Span::styled(
String::new(),
apply_relf_style(Style::default(), line_style),
));
}
lines_vec.push(Line::from(spans));
}
lines_vec
};
let title = match &app.file_path {
Some(path) => format!(" {} ", path.display()),
None => String::new(),
};
let content = Paragraph::new(content_text).block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::default().fg(Color::DarkGray).bg(Color::Rgb(26, 28, 34))),
);
f.render_widget(content, area);
}
fn render_relf_cards(f: &mut Frame, app: &mut App, area: Rect) {
let title = match &app.file_path {
Some(path) => format!(" {} ", path.display()),
None => String::new(),
};
let outer_block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::default().fg(Color::DarkGray));
let inner_area = outer_block.inner(area);
f.render_widget(outer_block, area);
app.content_width = inner_area.width;
app.visible_height = inner_area.height;
app.hscroll = 0;
let num_entries = app.relf_entries.len();
if num_entries == 0 {
return;
}
let selected = app.selected_entry_index;
let max_visible_cards = 5;
let scroll_start = if selected < max_visible_cards {
0
} else {
selected - max_visible_cards + 1
};
let visible_entries: Vec<(usize, &RelfEntry)> = app.relf_entries
.iter()
.enumerate()
.skip(scroll_start)
.take(max_visible_cards)
.collect();
if visible_entries.is_empty() {
return;
}
let constraints: Vec<Constraint> = visible_entries
.iter()
.map(|_| Constraint::Min(3)) .collect();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(inner_area);
for (i, (entry_idx, entry)) in visible_entries.iter().enumerate() {
let is_selected = *entry_idx == selected;
let mut lines = Vec::new();
if let Some(first) = entry.lines.first() {
if !app.search_query.is_empty() {
lines.push(highlight_search_in_line(
first,
&app.search_query,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
} else {
lines.push(Line::styled(
first.as_str(),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
}
}
for (idx, line) in entry.lines.iter().enumerate().skip(1) {
let fg = if idx == entry.lines.len() - 1 {
Color::Rgb(160, 200, 120)
} else if line.starts_with("http") {
Color::Rgb(120, 170, 255)
} else {
Color::Gray
};
if !app.search_query.is_empty() {
lines.push(highlight_search_in_line(
line,
&app.search_query,
Style::default().fg(fg),
));
} else {
lines.push(Line::styled(line.as_str(), Style::default().fg(fg)));
}
}
let border_style = if is_selected {
Style::default().fg(Color::Yellow).bg(entry.bg_color)
} else {
Style::default().bg(entry.bg_color)
};
let card = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(border_style),
);
f.render_widget(card, chunks[i]);
}
}
fn highlight_search_in_line<'a>(line: &'a str, query: &str, base_style: Style) -> Line<'a> {
let query_lower = query.to_lowercase();
let line_lower = line.to_lowercase();
let mut spans = Vec::new();
let mut byte_pos = 0;
while byte_pos < line_lower.len() {
if let Some(match_pos) = line_lower[byte_pos..].find(&query_lower) {
let actual_byte_pos = byte_pos + match_pos;
if actual_byte_pos > byte_pos && line.is_char_boundary(byte_pos) && line.is_char_boundary(actual_byte_pos) {
spans.push(Span::styled(
line[byte_pos..actual_byte_pos].to_string(),
base_style,
));
}
let match_end_byte = actual_byte_pos + query_lower.len();
if line.is_char_boundary(actual_byte_pos) && match_end_byte <= line.len() {
let safe_end = if line.is_char_boundary(match_end_byte) {
match_end_byte
} else {
(match_end_byte..=line.len())
.find(|&i| line.is_char_boundary(i))
.unwrap_or(line.len())
};
spans.push(Span::styled(
line[actual_byte_pos..safe_end].to_string(),
Style::default().fg(Color::Black).bg(Color::Cyan),
));
byte_pos = safe_end;
} else {
byte_pos = match_end_byte;
}
while byte_pos < line.len() && !line.is_char_boundary(byte_pos) {
byte_pos += 1;
}
} else {
break;
}
}
if byte_pos < line.len() && line.is_char_boundary(byte_pos) {
spans.push(Span::styled(line[byte_pos..].to_string(), base_style));
}
if spans.is_empty() {
spans.push(Span::styled(line.to_string(), base_style));
}
Line::from(spans)
}
fn apply_relf_style(mut style: Style, line_style: Option<&RelfLineStyle>) -> Style {
if let Some(ls) = line_style {
if let Some(fg) = ls.fg {
style = style.fg(fg);
}
if let Some(bg) = ls.bg {
style = style.bg(bg);
}
if ls.bold {
style = style.add_modifier(Modifier::BOLD);
}
}
style
}
fn render_edit_overlay(f: &mut Frame, app: &App) {
let area = f.area();
let popup_width = area.width.min(80);
let calculated_height = app.edit_buffer.len() as u16 + 4;
let max_height = (area.height * 7) / 10; let popup_height = calculated_height.max(max_height.min(area.height - 4));
let popup_area = Rect {
x: (area.width.saturating_sub(popup_width)) / 2,
y: (area.height.saturating_sub(popup_height)) / 2,
width: popup_width,
height: popup_height,
};
let title = if app.edit_buffer.len() == 2 {
" Edit INSIDE Entry "
} else {
" Edit OUTSIDE Entry "
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::default().bg(Color::Rgb(30, 30, 35)).fg(Color::White));
f.render_widget(block.clone(), popup_area);
let inner_area = block.inner(popup_area);
let mut lines = Vec::new();
for (i, field) in app.edit_buffer.iter().enumerate() {
let is_selected = i == app.edit_field_index;
let style = if is_selected {
if app.edit_insert_mode {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
}
} else {
Style::default().fg(Color::Gray)
};
let display_text = if is_selected && (app.edit_insert_mode || app.edit_field_editing_mode) {
let char_count = field.chars().count();
let cursor_char_pos = app.edit_cursor_pos.min(char_count);
let byte_pos = if cursor_char_pos == 0 {
0
} else if cursor_char_pos >= char_count {
field.len()
} else {
field.char_indices().nth(cursor_char_pos).map(|(i, _)| i).unwrap_or(field.len())
};
let mut text = field.clone();
text.insert(byte_pos, '|');
text
} else {
field.clone()
};
lines.push(Line::styled(display_text, style));
if i < app.edit_buffer.len() - 1 {
lines.push(Line::from(""));
}
}
let content = Paragraph::new(lines).wrap(Wrap { trim: false });
f.render_widget(content, inner_area);
}
fn render_status_bar(f: &mut Frame, app: &App, area: Rect) {
let mut spans = Vec::new();
if !app.status_message.is_empty() {
let status_text = format!(" {} ", app.status_message);
spans.push(Span::styled(
status_text,
Style::default().fg(Color::Cyan),
));
}
if app.format_mode == FormatMode::Edit {
let current_line = app.content_cursor_line + 1;
let current_col = app.content_cursor_col + 1;
let position_text = format!("{}:{} ", current_line, current_col);
let status_width = if !app.status_message.is_empty() {
app.status_message.len() + 2
} else {
0
};
let position_width = position_text.len();
let available_width = area.width as usize;
if available_width > status_width + position_width {
let padding_width = available_width - status_width - position_width;
spans.push(Span::raw(" ".repeat(padding_width)));
}
spans.push(Span::styled(
position_text,
Style::default().fg(Color::DarkGray),
));
}
let status_widget = Paragraph::new(Line::from(spans))
.alignment(Alignment::Left);
f.render_widget(status_widget, area);
}