mod base_tokens;
mod char_style;
mod folding;
mod gutter;
mod layout;
mod orchestration;
mod post_pass;
mod scrollbar;
mod spans;
mod style;
mod transforms;
mod view_data;
use crate::app::types::ViewLineMapping;
use crate::app::BufferMetadata;
use crate::model::buffer::Buffer;
use crate::model::event::{BufferId, EventLog, LeafId, SplitDirection};
use crate::primitives::ansi_background::AnsiBackground;
use crate::state::EditorState;
use crate::view::split::SplitManager;
use ratatui::layout::Rect;
use ratatui::Frame;
use std::collections::HashMap;
const MAX_SAFE_LINE_WIDTH: usize = 10_000;
pub struct SplitRenderer;
impl SplitRenderer {
#[allow(clippy::too_many_arguments)]
#[allow(clippy::type_complexity)]
pub fn render_content(
frame: &mut Frame,
area: Rect,
split_manager: &SplitManager,
buffers: &mut HashMap<BufferId, EditorState>,
buffer_metadata: &HashMap<BufferId, BufferMetadata>,
event_logs: &mut HashMap<BufferId, EventLog>,
composite_buffers: &mut HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
composite_view_states: &mut HashMap<
(LeafId, BufferId),
crate::view::composite_view::CompositeViewState,
>,
theme: &crate::view::theme::Theme,
ansi_background: Option<&AnsiBackground>,
background_fade: f32,
lsp_waiting: bool,
large_file_threshold_bytes: u64,
line_wrap: bool,
estimated_line_length: usize,
highlight_context_bytes: usize,
split_view_states: Option<&mut HashMap<LeafId, crate::view::split::SplitViewState>>,
grouped_subtrees: &HashMap<LeafId, crate::view::split::SplitNode>,
hide_cursor: bool,
hovered_tab: Option<(crate::view::split::TabTarget, LeafId, bool)>,
hovered_close_split: Option<LeafId>,
hovered_maximize_split: Option<LeafId>,
is_maximized: bool,
relative_line_numbers: bool,
tab_bar_visible: bool,
use_terminal_bg: bool,
session_mode: bool,
software_cursor_only: bool,
show_vertical_scrollbar: bool,
show_horizontal_scrollbar: bool,
diagnostics_inline_text: bool,
show_tilde: bool,
cell_theme_map: &mut Vec<crate::app::types::CellThemeInfo>,
screen_width: u16,
) -> (
Vec<(LeafId, BufferId, Rect, Rect, usize, usize)>,
HashMap<LeafId, crate::view::ui::tabs::TabLayout>,
Vec<(LeafId, u16, u16, u16)>,
Vec<(LeafId, u16, u16, u16)>,
HashMap<LeafId, Vec<ViewLineMapping>>,
Vec<(LeafId, BufferId, Rect, usize, usize, usize)>,
Vec<(
crate::model::event::ContainerId,
SplitDirection,
u16,
u16,
u16,
)>,
) {
orchestration::render_content(
frame,
area,
split_manager,
buffers,
buffer_metadata,
event_logs,
composite_buffers,
composite_view_states,
theme,
ansi_background,
background_fade,
lsp_waiting,
large_file_threshold_bytes,
line_wrap,
estimated_line_length,
highlight_context_bytes,
split_view_states,
grouped_subtrees,
hide_cursor,
hovered_tab,
hovered_close_split,
hovered_maximize_split,
is_maximized,
relative_line_numbers,
tab_bar_visible,
use_terminal_bg,
session_mode,
software_cursor_only,
show_vertical_scrollbar,
show_horizontal_scrollbar,
diagnostics_inline_text,
show_tilde,
cell_theme_map,
screen_width,
)
}
#[allow(clippy::too_many_arguments)]
pub fn compute_content_layout(
area: Rect,
split_manager: &SplitManager,
buffers: &mut HashMap<BufferId, EditorState>,
split_view_states: &mut HashMap<LeafId, crate::view::split::SplitViewState>,
theme: &crate::view::theme::Theme,
lsp_waiting: bool,
estimated_line_length: usize,
highlight_context_bytes: usize,
relative_line_numbers: bool,
use_terminal_bg: bool,
session_mode: bool,
software_cursor_only: bool,
tab_bar_visible: bool,
show_vertical_scrollbar: bool,
show_horizontal_scrollbar: bool,
diagnostics_inline_text: bool,
show_tilde: bool,
) -> HashMap<LeafId, Vec<ViewLineMapping>> {
orchestration::compute_content_layout(
area,
split_manager,
buffers,
split_view_states,
theme,
lsp_waiting,
estimated_line_length,
highlight_context_bytes,
relative_line_numbers,
use_terminal_bg,
session_mode,
software_cursor_only,
tab_bar_visible,
show_vertical_scrollbar,
show_horizontal_scrollbar,
diagnostics_inline_text,
show_tilde,
)
}
pub fn build_base_tokens_for_hook(
buffer: &mut Buffer,
top_byte: usize,
estimated_line_length: usize,
visible_count: usize,
is_binary: bool,
line_ending: crate::model::buffer::LineEnding,
) -> Vec<fresh_core::api::ViewTokenWire> {
orchestration::build_base_tokens_for_hook(
buffer,
top_byte,
estimated_line_length,
visible_count,
is_binary,
line_ending,
)
}
}
#[cfg(test)]
mod tests {
use super::folding::fold_indicators_for_viewport;
use super::layout::{calculate_view_anchor, calculate_viewport_end};
use super::orchestration::overlays::{decoration_context, selection_context};
use super::orchestration::render_buffer::resolve_cursor_fallback;
use super::orchestration::render_line::{
render_view_lines, LastLineEnd, LineRenderInput, LineRenderOutput,
};
use super::post_pass::apply_osc8_to_cells;
use super::transforms::apply_wrapping_transform;
use super::view_data::build_view_data;
use super::*;
use crate::model::buffer::{Buffer, LineEnding};
use crate::model::filesystem::StdFileSystem;
use crate::primitives::display_width::str_width;
use crate::state::{EditorState, ViewMode};
use crate::view::folding::FoldManager;
use crate::view::theme;
use crate::view::theme::Theme;
use crate::view::ui::view_pipeline::{LineStart, ViewLine};
use crate::view::viewport::Viewport;
use fresh_core::api::ViewTokenWire;
use lsp_types::FoldingRange;
use std::collections::HashSet;
use std::sync::Arc;
fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
Arc::new(StdFileSystem)
}
fn render_output_for(
content: &str,
cursor_pos: usize,
) -> (LineRenderOutput, usize, bool, usize) {
render_output_for_with_gutters(content, cursor_pos, false)
}
fn render_output_for_with_gutters(
content: &str,
cursor_pos: usize,
gutters_enabled: bool,
) -> (LineRenderOutput, usize, bool, usize) {
let mut state = EditorState::new(20, 6, 1024, test_fs());
state.buffer = Buffer::from_str(content, 1024, test_fs());
let mut cursors = crate::model::cursor::Cursors::new();
cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
let viewport = Viewport::new(20, 4);
state.margins.left_config.enabled = gutters_enabled;
let render_area = Rect::new(0, 0, 20, 4);
let visible_count = viewport.visible_line_count();
let gutter_width = state.margins.left_total_width();
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let empty_folds = FoldManager::new();
let view_data = build_view_data(
&mut state,
&viewport,
None,
content.len().max(1),
visible_count,
false, render_area.width as usize,
gutter_width,
&ViewMode::Source, &empty_folds,
&theme,
);
let view_anchor = calculate_view_anchor(&view_data.lines, 0);
let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
state.margins.update_width_for_buffer(estimated_lines, true);
let gutter_width = state.margins.left_total_width();
let selection = selection_context(&state, &cursors);
let _ = state
.buffer
.populate_line_cache(viewport.top_byte, visible_count);
let viewport_start = viewport.top_byte;
let viewport_end = calculate_viewport_end(
&mut state,
viewport_start,
content.len().max(1),
visible_count,
);
let decorations = decoration_context(
&mut state,
viewport_start,
viewport_end,
selection.primary_cursor_position,
&empty_folds,
&theme,
100_000, &ViewMode::Source, false, &[],
);
let mut dummy_theme_map = Vec::new();
let output = render_view_lines(LineRenderInput {
state: &state,
theme: &theme,
view_lines: &view_data.lines,
view_anchor,
render_area,
gutter_width,
selection: &selection,
decorations: &decorations,
visible_line_count: visible_count,
lsp_waiting: false,
is_active: true,
line_wrap: viewport.line_wrap_enabled,
estimated_lines,
left_column: viewport.left_column,
relative_line_numbers: false,
session_mode: false,
software_cursor_only: false,
show_line_numbers: true, byte_offset_mode: false, show_tilde: true,
highlight_current_line: true,
cell_theme_map: &mut dummy_theme_map,
screen_width: 0,
});
(
output,
state.buffer.len(),
content.ends_with('\n'),
selection.primary_cursor_position,
)
}
#[test]
fn test_folding_hides_lines_and_adds_placeholder() {
let content = "header\nline1\nline2\ntail\n";
let mut state = EditorState::new(40, 6, 1024, test_fs());
state.buffer = Buffer::from_str(content, 1024, test_fs());
let start = state.buffer.line_start_offset(1).unwrap();
let end = state.buffer.line_start_offset(3).unwrap();
let mut folds = FoldManager::new();
folds.add(&mut state.marker_list, start, end, Some("...".to_string()));
let viewport = Viewport::new(40, 6);
let gutter_width = state.margins.left_total_width();
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let view_data = build_view_data(
&mut state,
&viewport,
None,
content.len().max(1),
viewport.visible_line_count(),
false,
40,
gutter_width,
&ViewMode::Source,
&folds,
&theme,
);
let lines: Vec<String> = view_data.lines.iter().map(|l| l.text.clone()).collect();
assert!(lines.iter().any(|l| l.contains("header")));
assert!(lines.iter().any(|l| l.contains("tail")));
assert!(!lines.iter().any(|l| l.contains("line1")));
assert!(!lines.iter().any(|l| l.contains("line2")));
assert!(lines
.iter()
.any(|l| l.contains("header") && l.contains("...")));
}
#[test]
fn test_fold_indicators_collapsed_and_expanded() {
let content = "a\nb\nc\nd\n";
let mut state = EditorState::new(40, 6, 1024, test_fs());
state.buffer = Buffer::from_str(content, 1024, test_fs());
let lsp_ranges = vec![
FoldingRange {
start_line: 0,
end_line: 1,
start_character: None,
end_character: None,
kind: None,
collapsed_text: None,
},
FoldingRange {
start_line: 1,
end_line: 2,
start_character: None,
end_character: None,
kind: None,
collapsed_text: None,
},
];
state
.folding_ranges
.set_from_lsp(&state.buffer, &mut state.marker_list, lsp_ranges);
let start = state.buffer.line_start_offset(1).unwrap();
let end = state.buffer.line_start_offset(2).unwrap();
let mut folds = FoldManager::new();
folds.add(&mut state.marker_list, start, end, None);
let line1_byte = state.buffer.line_start_offset(1).unwrap();
let view_lines = vec![ViewLine {
text: "b\n".to_string(),
source_start_byte: Some(line1_byte),
char_source_bytes: vec![Some(line1_byte), Some(line1_byte + 1)],
char_styles: vec![None, None],
char_visual_cols: vec![0, 1],
visual_to_char: vec![0, 1],
tab_starts: HashSet::new(),
line_start: LineStart::AfterSourceNewline,
ends_with_newline: true,
}];
let indicators = fold_indicators_for_viewport(&state, &folds, &view_lines);
assert_eq!(indicators.get(&0).map(|i| i.collapsed), Some(true));
assert_eq!(
indicators.get(&line1_byte).map(|i| i.collapsed),
Some(false)
);
}
#[test]
fn last_line_end_tracks_trailing_newline() {
let output = render_output_for("abc\n", 4);
assert_eq!(
output.0.last_line_end,
Some(LastLineEnd {
pos: (3, 0),
terminated_with_newline: true
})
);
}
#[test]
fn last_line_end_tracks_no_trailing_newline() {
let output = render_output_for("abc", 3);
assert_eq!(
output.0.last_line_end,
Some(LastLineEnd {
pos: (3, 0),
terminated_with_newline: false
})
);
}
#[test]
fn cursor_after_newline_places_on_next_line() {
let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc\n", 4);
let cursor = resolve_cursor_fallback(
output.cursor,
cursor_pos,
buffer_len,
buffer_newline,
output.last_line_end,
output.content_lines_rendered,
0, );
assert_eq!(cursor, Some((0, 1)));
}
#[test]
fn cursor_at_end_without_newline_stays_on_line() {
let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc", 3);
let cursor = resolve_cursor_fallback(
output.cursor,
cursor_pos,
buffer_len,
buffer_newline,
output.last_line_end,
output.content_lines_rendered,
0, );
assert_eq!(cursor, Some((3, 0)));
}
fn count_all_cursors(output: &LineRenderOutput) -> Vec<(u16, u16)> {
let mut cursor_positions = Vec::new();
let primary_cursor = output.cursor;
if let Some(cursor_pos) = primary_cursor {
cursor_positions.push(cursor_pos);
}
for (line_idx, line) in output.lines.iter().enumerate() {
let mut col = 0u16;
for span in line.spans.iter() {
if span
.style
.add_modifier
.contains(ratatui::style::Modifier::REVERSED)
{
let pos = (col, line_idx as u16);
if primary_cursor != Some(pos) {
cursor_positions.push(pos);
}
}
col += str_width(&span.content) as u16;
}
}
cursor_positions
}
fn dump_render_output(content: &str, cursor_pos: usize, output: &LineRenderOutput) {
eprintln!("\n=== RENDER DEBUG ===");
eprintln!("Content: {:?}", content);
eprintln!("Cursor position: {}", cursor_pos);
eprintln!("Hardware cursor (output.cursor): {:?}", output.cursor);
eprintln!("Last line end: {:?}", output.last_line_end);
eprintln!("Content lines rendered: {}", output.content_lines_rendered);
eprintln!("\nRendered lines:");
for (line_idx, line) in output.lines.iter().enumerate() {
eprintln!(" Line {}: {} spans", line_idx, line.spans.len());
for (span_idx, span) in line.spans.iter().enumerate() {
let has_reversed = span
.style
.add_modifier
.contains(ratatui::style::Modifier::REVERSED);
let bg_color = format!("{:?}", span.style.bg);
eprintln!(
" Span {}: {:?} (REVERSED: {}, BG: {})",
span_idx, span.content, has_reversed, bg_color
);
}
}
eprintln!("===================\n");
}
fn get_final_cursor(content: &str, cursor_pos: usize) -> Option<(u16, u16)> {
let (output, buffer_len, buffer_newline, cursor_pos) =
render_output_for(content, cursor_pos);
let all_cursors = count_all_cursors(&output);
assert!(
all_cursors.len() <= 1,
"Expected at most 1 cursor in rendered output, found {} at positions: {:?}",
all_cursors.len(),
all_cursors
);
let final_cursor = resolve_cursor_fallback(
output.cursor,
cursor_pos,
buffer_len,
buffer_newline,
output.last_line_end,
output.content_lines_rendered,
0, );
if all_cursors.len() > 1 || (all_cursors.len() == 1 && Some(all_cursors[0]) != final_cursor)
{
dump_render_output(content, cursor_pos, &output);
}
if let Some(rendered_cursor) = all_cursors.first() {
assert_eq!(
Some(*rendered_cursor),
final_cursor,
"Rendered cursor at {:?} doesn't match final cursor {:?}",
rendered_cursor,
final_cursor
);
}
assert!(
final_cursor.is_some(),
"Expected a final cursor position, but got None. Rendered cursors: {:?}",
all_cursors
);
final_cursor
}
fn check_typing_at_cursor(
content: &str,
cursor_pos: usize,
char_to_type: char,
) -> (Option<(u16, u16)>, String) {
let cursor_before = get_final_cursor(content, cursor_pos);
let mut new_content = content.to_string();
if cursor_pos <= content.len() {
new_content.insert(cursor_pos, char_to_type);
}
(cursor_before, new_content)
}
#[test]
fn e2e_cursor_at_start_of_nonempty_line() {
let cursor = get_final_cursor("abc", 0);
assert_eq!(cursor, Some((0, 0)), "Cursor should be at column 0, line 0");
let (cursor_pos, new_content) = check_typing_at_cursor("abc", 0, 'X');
assert_eq!(
new_content, "Xabc",
"Typing should insert at cursor position"
);
assert_eq!(cursor_pos, Some((0, 0)));
}
#[test]
fn e2e_cursor_in_middle_of_line() {
let cursor = get_final_cursor("abc", 1);
assert_eq!(cursor, Some((1, 0)), "Cursor should be at column 1, line 0");
let (cursor_pos, new_content) = check_typing_at_cursor("abc", 1, 'X');
assert_eq!(
new_content, "aXbc",
"Typing should insert at cursor position"
);
assert_eq!(cursor_pos, Some((1, 0)));
}
#[test]
fn e2e_cursor_at_end_of_line_no_newline() {
let cursor = get_final_cursor("abc", 3);
assert_eq!(
cursor,
Some((3, 0)),
"Cursor should be at column 3, line 0 (after last char)"
);
let (cursor_pos, new_content) = check_typing_at_cursor("abc", 3, 'X');
assert_eq!(new_content, "abcX", "Typing should append at end");
assert_eq!(cursor_pos, Some((3, 0)));
}
#[test]
fn e2e_cursor_at_empty_line() {
let cursor = get_final_cursor("\n", 0);
assert_eq!(
cursor,
Some((0, 0)),
"Cursor on empty line should be at column 0"
);
let (cursor_pos, new_content) = check_typing_at_cursor("\n", 0, 'X');
assert_eq!(new_content, "X\n", "Typing should insert before newline");
assert_eq!(cursor_pos, Some((0, 0)));
}
#[test]
fn e2e_cursor_after_newline_at_eof() {
let cursor = get_final_cursor("abc\n", 4);
assert_eq!(
cursor,
Some((0, 1)),
"Cursor after newline at EOF should be on next line"
);
let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 4, 'X');
assert_eq!(new_content, "abc\nX", "Typing should insert on new line");
assert_eq!(cursor_pos, Some((0, 1)));
}
#[test]
fn e2e_cursor_on_newline_with_content() {
let cursor = get_final_cursor("abc\n", 3);
assert_eq!(
cursor,
Some((3, 0)),
"Cursor on newline after content should be after last char"
);
let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 3, 'X');
assert_eq!(new_content, "abcX\n", "Typing should insert before newline");
assert_eq!(cursor_pos, Some((3, 0)));
}
#[test]
fn e2e_cursor_multiline_start_of_second_line() {
let cursor = get_final_cursor("abc\ndef", 4);
assert_eq!(
cursor,
Some((0, 1)),
"Cursor at start of second line should be at column 0, line 1"
);
let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 4, 'X');
assert_eq!(
new_content, "abc\nXdef",
"Typing should insert at start of second line"
);
assert_eq!(cursor_pos, Some((0, 1)));
}
#[test]
fn e2e_cursor_multiline_end_of_first_line() {
let cursor = get_final_cursor("abc\ndef", 3);
assert_eq!(
cursor,
Some((3, 0)),
"Cursor on newline of first line should be after content"
);
let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 3, 'X');
assert_eq!(
new_content, "abcX\ndef",
"Typing should insert before newline"
);
assert_eq!(cursor_pos, Some((3, 0)));
}
#[test]
fn e2e_cursor_empty_buffer() {
let cursor = get_final_cursor("", 0);
assert_eq!(
cursor,
Some((0, 0)),
"Cursor in empty buffer should be at origin"
);
let (cursor_pos, new_content) = check_typing_at_cursor("", 0, 'X');
assert_eq!(
new_content, "X",
"Typing in empty buffer should insert character"
);
assert_eq!(cursor_pos, Some((0, 0)));
}
#[test]
fn e2e_cursor_empty_buffer_with_gutters() {
let (output, buffer_len, buffer_newline, cursor_pos) =
render_output_for_with_gutters("", 0, true);
let gutter_width = {
let mut state = EditorState::new(20, 6, 1024, test_fs());
state.margins.left_config.enabled = true;
state.margins.update_width_for_buffer(1, true);
state.margins.left_total_width()
};
assert!(gutter_width > 0, "Gutter width should be > 0 when enabled");
assert_eq!(
output.cursor,
Some((gutter_width as u16, 0)),
"RENDERED cursor in empty buffer should be at gutter_width ({}), got {:?}",
gutter_width,
output.cursor
);
let final_cursor = resolve_cursor_fallback(
output.cursor,
cursor_pos,
buffer_len,
buffer_newline,
output.last_line_end,
output.content_lines_rendered,
gutter_width,
);
assert_eq!(
final_cursor,
Some((gutter_width as u16, 0)),
"Cursor in empty buffer with gutters should be at gutter_width, not column 0"
);
}
#[test]
fn e2e_cursor_between_empty_lines() {
let cursor = get_final_cursor("\n\n", 1);
assert_eq!(cursor, Some((0, 1)), "Cursor on second empty line");
let (cursor_pos, new_content) = check_typing_at_cursor("\n\n", 1, 'X');
assert_eq!(new_content, "\nX\n", "Typing should insert on second line");
assert_eq!(cursor_pos, Some((0, 1)));
}
#[test]
fn e2e_cursor_at_eof_after_multiple_lines() {
let cursor = get_final_cursor("abc\ndef\nghi", 11);
assert_eq!(
cursor,
Some((3, 2)),
"Cursor at EOF after 'i' should be at column 3, line 2"
);
let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi", 11, 'X');
assert_eq!(new_content, "abc\ndef\nghiX", "Typing should append at end");
assert_eq!(cursor_pos, Some((3, 2)));
}
#[test]
fn e2e_cursor_at_eof_with_trailing_newline() {
let cursor = get_final_cursor("abc\ndef\nghi\n", 12);
assert_eq!(
cursor,
Some((0, 3)),
"Cursor after trailing newline should be on line 3"
);
let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi\n", 12, 'X');
assert_eq!(
new_content, "abc\ndef\nghi\nX",
"Typing should insert on new line"
);
assert_eq!(cursor_pos, Some((0, 3)));
}
#[test]
fn e2e_jump_to_end_of_buffer_no_trailing_newline() {
let content = "abc\ndef\nghi";
let cursor_at_start = get_final_cursor(content, 0);
assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
let cursor_at_eof = get_final_cursor(content, 11);
assert_eq!(
cursor_at_eof,
Some((3, 2)),
"After Ctrl+End, cursor at column 3, line 2"
);
let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 11, 'X');
assert_eq!(cursor_before_typing, Some((3, 2)));
assert_eq!(new_content, "abc\ndef\nghiX", "Character appended at end");
let cursor_after_typing = get_final_cursor(&new_content, 12);
assert_eq!(
cursor_after_typing,
Some((4, 2)),
"After typing, cursor moved to column 4"
);
let cursor_moved_away = get_final_cursor(&new_content, 0);
assert_eq!(cursor_moved_away, Some((0, 0)), "Cursor moved to start");
}
#[test]
fn e2e_jump_to_end_of_buffer_with_trailing_newline() {
let content = "abc\ndef\nghi\n";
let cursor_at_start = get_final_cursor(content, 0);
assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
let cursor_at_eof = get_final_cursor(content, 12);
assert_eq!(
cursor_at_eof,
Some((0, 3)),
"After Ctrl+End, cursor at column 0, line 3 (new line)"
);
let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 12, 'X');
assert_eq!(cursor_before_typing, Some((0, 3)));
assert_eq!(
new_content, "abc\ndef\nghi\nX",
"Character inserted on new line"
);
let cursor_after_typing = get_final_cursor(&new_content, 13);
assert_eq!(
cursor_after_typing,
Some((1, 3)),
"After typing, cursor should be at column 1, line 3"
);
let cursor_moved_away = get_final_cursor(&new_content, 4);
assert_eq!(
cursor_moved_away,
Some((0, 1)),
"Cursor moved to start of line 1 (position 4 = start of 'def')"
);
}
#[test]
fn e2e_jump_to_end_of_empty_buffer() {
let content = "";
let cursor_at_eof = get_final_cursor(content, 0);
assert_eq!(
cursor_at_eof,
Some((0, 0)),
"Empty buffer: cursor at origin"
);
let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 0, 'X');
assert_eq!(cursor_before_typing, Some((0, 0)));
assert_eq!(new_content, "X", "Character inserted");
let cursor_after_typing = get_final_cursor(&new_content, 1);
assert_eq!(
cursor_after_typing,
Some((1, 0)),
"After typing, cursor at column 1"
);
let cursor_moved_away = get_final_cursor(&new_content, 0);
assert_eq!(
cursor_moved_away,
Some((0, 0)),
"Cursor moved back to start"
);
}
#[test]
fn e2e_jump_to_end_of_single_empty_line() {
let content = "\n";
let cursor_on_newline = get_final_cursor(content, 0);
assert_eq!(
cursor_on_newline,
Some((0, 0)),
"Cursor on the newline character"
);
let cursor_at_eof = get_final_cursor(content, 1);
assert_eq!(
cursor_at_eof,
Some((0, 1)),
"After Ctrl+End, cursor on line 1"
);
let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 1, 'X');
assert_eq!(cursor_before_typing, Some((0, 1)));
assert_eq!(new_content, "\nX", "Character on second line");
let cursor_after_typing = get_final_cursor(&new_content, 2);
assert_eq!(
cursor_after_typing,
Some((1, 1)),
"After typing, cursor at column 1, line 1"
);
let cursor_moved_away = get_final_cursor(&new_content, 0);
assert_eq!(
cursor_moved_away,
Some((0, 0)),
"Cursor moved to the newline on line 0"
);
}
use fresh_core::api::ViewTokenWireKind;
fn extract_token_offsets(tokens: &[ViewTokenWire]) -> Vec<(String, Option<usize>)> {
tokens
.iter()
.map(|t| {
let kind_str = match &t.kind {
ViewTokenWireKind::Text(s) => format!("Text({})", s),
ViewTokenWireKind::Newline => "Newline".to_string(),
ViewTokenWireKind::Space => "Space".to_string(),
ViewTokenWireKind::Break => "Break".to_string(),
ViewTokenWireKind::BinaryByte(b) => format!("Byte(0x{:02x})", b),
};
(kind_str, t.source_offset)
})
.collect()
}
#[test]
fn test_build_base_tokens_crlf_single_line() {
let content = b"abc\r\n";
let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
buffer.set_line_ending(LineEnding::CRLF);
let tokens = SplitRenderer::build_base_tokens_for_hook(
&mut buffer,
0, 80, 10, false, LineEnding::CRLF,
);
let offsets = extract_token_offsets(&tokens);
assert!(
offsets
.iter()
.any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
"Expected Text(abc) at offset 0, got: {:?}",
offsets
);
assert!(
offsets
.iter()
.any(|(kind, off)| kind == "Newline" && *off == Some(3)),
"Expected Newline at offset 3 (\\r position), got: {:?}",
offsets
);
let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
assert_eq!(
newline_count, 1,
"Should have exactly 1 Newline token for CRLF, got {}: {:?}",
newline_count, offsets
);
}
#[test]
fn test_build_base_tokens_crlf_multiple_lines() {
let content = b"abc\r\ndef\r\nghi\r\n";
let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
buffer.set_line_ending(LineEnding::CRLF);
let tokens = SplitRenderer::build_base_tokens_for_hook(
&mut buffer,
0,
80,
10,
false,
LineEnding::CRLF,
);
let offsets = extract_token_offsets(&tokens);
assert!(
offsets
.iter()
.any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
"Line 1: Expected Text(abc) at 0, got: {:?}",
offsets
);
assert!(
offsets
.iter()
.any(|(kind, off)| kind == "Newline" && *off == Some(3)),
"Line 1: Expected Newline at 3, got: {:?}",
offsets
);
assert!(
offsets
.iter()
.any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
"Line 2: Expected Text(def) at 5, got: {:?}",
offsets
);
assert!(
offsets
.iter()
.any(|(kind, off)| kind == "Newline" && *off == Some(8)),
"Line 2: Expected Newline at 8, got: {:?}",
offsets
);
assert!(
offsets
.iter()
.any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
"Line 3: Expected Text(ghi) at 10, got: {:?}",
offsets
);
assert!(
offsets
.iter()
.any(|(kind, off)| kind == "Newline" && *off == Some(13)),
"Line 3: Expected Newline at 13, got: {:?}",
offsets
);
let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
assert_eq!(newline_count, 3, "Should have 3 Newline tokens");
}
#[test]
fn test_build_base_tokens_lf_mode_for_comparison() {
let content = b"abc\ndef\n";
let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
buffer.set_line_ending(LineEnding::LF);
let tokens = SplitRenderer::build_base_tokens_for_hook(
&mut buffer,
0,
80,
10,
false,
LineEnding::LF,
);
let offsets = extract_token_offsets(&tokens);
assert!(
offsets
.iter()
.any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
"LF Line 1: Expected Text(abc) at 0"
);
assert!(
offsets
.iter()
.any(|(kind, off)| kind == "Newline" && *off == Some(3)),
"LF Line 1: Expected Newline at 3"
);
assert!(
offsets
.iter()
.any(|(kind, off)| kind == "Text(def)" && *off == Some(4)),
"LF Line 2: Expected Text(def) at 4"
);
assert!(
offsets
.iter()
.any(|(kind, off)| kind == "Newline" && *off == Some(7)),
"LF Line 2: Expected Newline at 7"
);
}
#[test]
fn test_build_base_tokens_crlf_in_lf_mode_shows_control_char() {
let content = b"abc\r\n";
let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
buffer.set_line_ending(LineEnding::LF);
let tokens = SplitRenderer::build_base_tokens_for_hook(
&mut buffer,
0,
80,
10,
false,
LineEnding::LF,
);
let offsets = extract_token_offsets(&tokens);
assert!(
offsets.iter().any(|(kind, _)| kind == "Byte(0x0d)"),
"LF mode should render \\r as control char <0D>, got: {:?}",
offsets
);
}
#[test]
fn test_build_base_tokens_crlf_from_middle() {
let content = b"abc\r\ndef\r\nghi\r\n";
let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
buffer.set_line_ending(LineEnding::CRLF);
let tokens = SplitRenderer::build_base_tokens_for_hook(
&mut buffer,
5, 80,
10,
false,
LineEnding::CRLF,
);
let offsets = extract_token_offsets(&tokens);
assert!(
offsets
.iter()
.any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
"Starting from byte 5: Expected Text(def) at 5, got: {:?}",
offsets
);
assert!(
offsets
.iter()
.any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
"Starting from byte 5: Expected Text(ghi) at 10, got: {:?}",
offsets
);
}
#[test]
fn test_crlf_highlight_span_lookup() {
use crate::view::ui::view_pipeline::ViewLineIterator;
let content = b"int x;\r\nint y;\r\n";
let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
buffer.set_line_ending(LineEnding::CRLF);
let tokens = SplitRenderer::build_base_tokens_for_hook(
&mut buffer,
0,
80,
10,
false,
LineEnding::CRLF,
);
let offsets = extract_token_offsets(&tokens);
eprintln!("Tokens: {:?}", offsets);
let view_lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
assert_eq!(view_lines.len(), 2, "Should have 2 view lines");
eprintln!(
"Line 1 char_source_bytes: {:?}",
view_lines[0].char_source_bytes
);
assert_eq!(
view_lines[0].char_source_bytes.len(),
7,
"Line 1 should have 7 chars: 'i','n','t',' ','x',';','\\n'"
);
assert_eq!(
view_lines[0].char_source_bytes[0],
Some(0),
"Line 1 'i' -> byte 0"
);
assert_eq!(
view_lines[0].char_source_bytes[4],
Some(4),
"Line 1 'x' -> byte 4"
);
assert_eq!(
view_lines[0].char_source_bytes[5],
Some(5),
"Line 1 ';' -> byte 5"
);
assert_eq!(
view_lines[0].char_source_bytes[6],
Some(6),
"Line 1 newline -> byte 6 (\\r pos)"
);
eprintln!(
"Line 2 char_source_bytes: {:?}",
view_lines[1].char_source_bytes
);
assert_eq!(
view_lines[1].char_source_bytes.len(),
7,
"Line 2 should have 7 chars: 'i','n','t',' ','y',';','\\n'"
);
assert_eq!(
view_lines[1].char_source_bytes[0],
Some(8),
"Line 2 'i' -> byte 8"
);
assert_eq!(
view_lines[1].char_source_bytes[4],
Some(12),
"Line 2 'y' -> byte 12"
);
assert_eq!(
view_lines[1].char_source_bytes[5],
Some(13),
"Line 2 ';' -> byte 13"
);
assert_eq!(
view_lines[1].char_source_bytes[6],
Some(14),
"Line 2 newline -> byte 14 (\\r pos)"
);
let simulated_highlight_spans = [
(0usize..3usize, "keyword"),
(8usize..11usize, "keyword"),
];
for (line_idx, view_line) in view_lines.iter().enumerate() {
for (char_idx, byte_pos) in view_line.char_source_bytes.iter().enumerate() {
if let Some(bp) = byte_pos {
let in_span = simulated_highlight_spans
.iter()
.find(|(range, _)| range.contains(bp))
.map(|(_, name)| *name);
let expected_in_keyword = char_idx < 3;
let actually_in_keyword = in_span == Some("keyword");
if expected_in_keyword != actually_in_keyword {
panic!(
"CRLF offset drift detected! Line {} char {} (byte {}): expected keyword={}, got keyword={}",
line_idx + 1, char_idx, bp, expected_in_keyword, actually_in_keyword
);
}
}
}
}
}
#[test]
fn test_apply_wrapping_transform_breaks_long_lines() {
use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
let long_text = "x".repeat(25_000);
let tokens = vec![
ViewTokenWire {
kind: ViewTokenWireKind::Text(long_text),
source_offset: Some(0),
style: None,
},
ViewTokenWire {
kind: ViewTokenWireKind::Newline,
source_offset: Some(25_000),
style: None,
},
];
let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
let break_count = wrapped
.iter()
.filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
.count();
assert!(
break_count >= 2,
"25K char line should have at least 2 breaks at 10K width, got {}",
break_count
);
let total_chars: usize = wrapped
.iter()
.filter_map(|t| match &t.kind {
ViewTokenWireKind::Text(s) => Some(s.len()),
_ => None,
})
.sum();
assert_eq!(
total_chars, 25_000,
"Total character count should be preserved after wrapping"
);
}
#[test]
fn test_apply_wrapping_transform_preserves_short_lines() {
use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
let short_text = "x".repeat(100);
let tokens = vec![
ViewTokenWire {
kind: ViewTokenWireKind::Text(short_text.clone()),
source_offset: Some(0),
style: None,
},
ViewTokenWire {
kind: ViewTokenWireKind::Newline,
source_offset: Some(100),
style: None,
},
];
let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
let break_count = wrapped
.iter()
.filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
.count();
assert_eq!(
break_count, 0,
"Short lines should not have any breaks, got {}",
break_count
);
let text_tokens: Vec<_> = wrapped
.iter()
.filter_map(|t| match &t.kind {
ViewTokenWireKind::Text(s) => Some(s.clone()),
_ => None,
})
.collect();
assert_eq!(text_tokens.len(), 1, "Should have exactly one Text token");
assert_eq!(
text_tokens[0], short_text,
"Text content should be unchanged"
);
}
#[test]
fn test_large_single_line_sequential_data_preserved() {
use crate::view::ui::view_pipeline::ViewLineIterator;
use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
let num_markers = 5_000; let content: String = (1..=num_markers).map(|i| format!("[{:05}]", i)).collect();
let tokens = vec![
ViewTokenWire {
kind: ViewTokenWireKind::Text(content.clone()),
source_offset: Some(0),
style: None,
},
ViewTokenWire {
kind: ViewTokenWireKind::Newline,
source_offset: Some(content.len()),
style: None,
},
];
let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
let view_lines: Vec<_> = ViewLineIterator::new(&wrapped, false, false, 4, false).collect();
let mut reconstructed = String::new();
for line in &view_lines {
let text = line.text.trim_end_matches('\n');
reconstructed.push_str(text);
}
assert_eq!(
reconstructed.len(),
content.len(),
"Reconstructed content length should match original"
);
for i in 1..=num_markers {
let marker = format!("[{:05}]", i);
assert!(
reconstructed.contains(&marker),
"Missing marker {} after pipeline",
marker
);
}
let pos_100 = reconstructed.find("[00100]").expect("Should find [00100]");
let pos_1000 = reconstructed.find("[01000]").expect("Should find [01000]");
let pos_3000 = reconstructed.find("[03000]").expect("Should find [03000]");
assert!(
pos_100 < pos_1000 && pos_1000 < pos_3000,
"Markers should be in sequential order: {} < {} < {}",
pos_100,
pos_1000,
pos_3000
);
assert!(
view_lines.len() >= 3,
"35KB content should produce multiple visual lines at 10K width, got {}",
view_lines.len()
);
for (i, line) in view_lines.iter().enumerate() {
assert!(
line.text.len() <= MAX_SAFE_LINE_WIDTH + 10, "ViewLine {} exceeds safe width: {} chars",
i,
line.text.len()
);
}
}
fn strip_osc8(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 3 < bytes.len()
&& bytes[i] == 0x1b
&& bytes[i + 1] == b']'
&& bytes[i + 2] == b'8'
&& bytes[i + 3] == b';'
{
i += 4;
while i < bytes.len() && bytes[i] != 0x07 {
i += 1;
}
if i < bytes.len() {
i += 1;
}
} else {
result.push(bytes[i] as char);
i += 1;
}
}
result
}
fn read_row(buf: &ratatui::buffer::Buffer, y: u16) -> String {
let width = buf.area().width;
let mut s = String::new();
let mut col = 0u16;
while col < width {
let cell = &buf[(col, y)];
let stripped = strip_osc8(cell.symbol());
let chars = stripped.chars().count();
if chars > 1 {
s.push_str(&stripped);
col += chars as u16;
} else {
s.push_str(&stripped);
col += 1;
}
}
s.trim_end().to_string()
}
#[test]
fn test_apply_osc8_to_cells_preserves_adjacent_cells() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let text = "[Quick Install](#installation)";
let area = Rect::new(0, 0, 40, 1);
let mut buf = Buffer::empty(area);
for (i, ch) in text.chars().enumerate() {
if (i as u16) < 40 {
buf[(i as u16, 0)].set_symbol(&ch.to_string());
}
}
let url = "https://example.com";
apply_osc8_to_cells(&mut buf, 1, 14, 0, url, Some((0, 0)));
let row = read_row(&buf, 0);
assert_eq!(
row, text,
"After OSC 8 application, reading the row should reproduce the original text"
);
let cell14 = strip_osc8(buf[(14, 0)].symbol());
assert_eq!(cell14, "]", "Cell 14 (']') must not be modified by OSC 8");
let cell0 = strip_osc8(buf[(0, 0)].symbol());
assert_eq!(cell0, "[", "Cell 0 ('[') must not be modified by OSC 8");
}
#[test]
fn test_apply_osc8_stable_across_reapply() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let text = "[Quick Install](#installation)";
let area = Rect::new(0, 0, 40, 1);
let mut buf1 = Buffer::empty(area);
for (i, ch) in text.chars().enumerate() {
if (i as u16) < 40 {
buf1[(i as u16, 0)].set_symbol(&ch.to_string());
}
}
apply_osc8_to_cells(&mut buf1, 1, 14, 0, "https://example.com", Some((0, 0)));
let row1 = read_row(&buf1, 0);
let mut buf2 = Buffer::empty(area);
for (i, ch) in text.chars().enumerate() {
if (i as u16) < 40 {
buf2[(i as u16, 0)].set_symbol(&ch.to_string());
}
}
apply_osc8_to_cells(&mut buf2, 1, 14, 0, "https://example.com", Some((5, 0)));
let row2 = read_row(&buf2, 0);
assert_eq!(row1, text);
assert_eq!(row2, text);
}
#[test]
#[ignore = "OSC 8 hyperlinks disabled pending ratatui diff fix"]
fn test_apply_osc8_diff_between_renders() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let area = Rect::new(0, 0, 40, 1);
let concealed = "Quick Install";
let mut frame1 = Buffer::empty(area);
for (i, ch) in concealed.chars().enumerate() {
frame1[(i as u16, 0)].set_symbol(&ch.to_string());
}
apply_osc8_to_cells(&mut frame1, 0, 13, 0, "https://example.com", Some((0, 5)));
let prev = Buffer::empty(area);
let mut backend = Buffer::empty(area);
let diff1 = prev.diff(&frame1);
for (x, y, cell) in &diff1 {
backend[(*x, *y)] = (*cell).clone();
}
let full = "[Quick Install](#installation)";
let mut frame2 = Buffer::empty(area);
for (i, ch) in full.chars().enumerate() {
if (i as u16) < 40 {
frame2[(i as u16, 0)].set_symbol(&ch.to_string());
}
}
apply_osc8_to_cells(&mut frame2, 1, 14, 0, "https://example.com", Some((0, 0)));
let diff2 = frame1.diff(&frame2);
for (x, y, cell) in &diff2 {
backend[(*x, *y)] = (*cell).clone();
}
let row = read_row(&backend, 0);
assert_eq!(
row, full,
"After diff-based update from concealed to unconcealed, \
backend should show full text"
);
let cell14 = strip_osc8(backend[(14, 0)].symbol());
assert_eq!(cell14, "]", "Cell 14 must be ']' after unconcealed render");
}
fn render_with_highlight_option(
content: &str,
cursor_pos: usize,
highlight_current_line: bool,
) -> LineRenderOutput {
let mut state = EditorState::new(20, 6, 1024, test_fs());
state.buffer = Buffer::from_str(content, 1024, test_fs());
let mut cursors = crate::model::cursor::Cursors::new();
cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
let viewport = Viewport::new(20, 4);
state.margins.left_config.enabled = false;
let render_area = Rect::new(0, 0, 20, 4);
let visible_count = viewport.visible_line_count();
let gutter_width = state.margins.left_total_width();
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let empty_folds = FoldManager::new();
let view_data = build_view_data(
&mut state,
&viewport,
None,
content.len().max(1),
visible_count,
false,
render_area.width as usize,
gutter_width,
&ViewMode::Source,
&empty_folds,
&theme,
);
let view_anchor = calculate_view_anchor(&view_data.lines, 0);
let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
state.margins.update_width_for_buffer(estimated_lines, true);
let gutter_width = state.margins.left_total_width();
let selection = selection_context(&state, &cursors);
let _ = state
.buffer
.populate_line_cache(viewport.top_byte, visible_count);
let viewport_start = viewport.top_byte;
let viewport_end = calculate_viewport_end(
&mut state,
viewport_start,
content.len().max(1),
visible_count,
);
let decorations = decoration_context(
&mut state,
viewport_start,
viewport_end,
selection.primary_cursor_position,
&empty_folds,
&theme,
100_000,
&ViewMode::Source,
false,
&[],
);
render_view_lines(LineRenderInput {
state: &state,
theme: &theme,
view_lines: &view_data.lines,
view_anchor,
render_area,
gutter_width,
selection: &selection,
decorations: &decorations,
visible_line_count: visible_count,
lsp_waiting: false,
is_active: true,
line_wrap: viewport.line_wrap_enabled,
estimated_lines,
left_column: viewport.left_column,
relative_line_numbers: false,
session_mode: false,
software_cursor_only: false,
show_line_numbers: false,
byte_offset_mode: false,
show_tilde: true,
highlight_current_line,
cell_theme_map: &mut Vec::new(),
screen_width: 0,
})
}
fn line_has_current_line_bg(output: &LineRenderOutput, line_idx: usize) -> bool {
let current_line_bg = ratatui::style::Color::Rgb(40, 40, 40);
if let Some(line) = output.lines.get(line_idx) {
line.spans
.iter()
.any(|span| span.style.bg == Some(current_line_bg))
} else {
false
}
}
#[test]
fn current_line_highlight_enabled_highlights_cursor_line() {
let output = render_with_highlight_option("abc\ndef\nghi\n", 0, true);
assert!(
line_has_current_line_bg(&output, 0),
"Cursor line (line 0) should have current_line_bg when highlighting is enabled"
);
assert!(
!line_has_current_line_bg(&output, 1),
"Non-cursor line (line 1) should NOT have current_line_bg"
);
}
#[test]
fn current_line_highlight_disabled_no_highlight() {
let output = render_with_highlight_option("abc\ndef\nghi\n", 0, false);
assert!(
!line_has_current_line_bg(&output, 0),
"Cursor line should NOT have current_line_bg when highlighting is disabled"
);
assert!(
!line_has_current_line_bg(&output, 1),
"Non-cursor line should NOT have current_line_bg when highlighting is disabled"
);
}
#[test]
fn current_line_highlight_follows_cursor_position() {
let output = render_with_highlight_option("abc\ndef\nghi\n", 4, true);
assert!(
!line_has_current_line_bg(&output, 0),
"Line 0 should NOT have current_line_bg when cursor is on line 1"
);
assert!(
line_has_current_line_bg(&output, 1),
"Line 1 should have current_line_bg when cursor is there"
);
assert!(
!line_has_current_line_bg(&output, 2),
"Line 2 should NOT have current_line_bg when cursor is on line 1"
);
}
}