Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 31 additions & 73 deletions crates/fresh-editor/src/view/folding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,53 +307,34 @@ pub mod indent_folding {
(indent, all_blank)
}

/// Identify foldable lines in a raw byte slice by analysing indentation.
///
/// Works without any line metadata, so it can be used on large files whose
/// piece tree has not been scanned for line feeds.
///
/// `max_lookahead` limits how many lines *ahead* of each candidate we scan
/// to decide foldability.
///
/// Returns an iterator of 0-based line indices (within the slice) that are
/// foldable.
pub fn foldable_lines_in_bytes(
bytes: &[u8],
tab_size: usize,
max_lookahead: usize,
) -> Vec<usize> {
// Split into lines (preserving empty trailing line if present).
let lines: Vec<&[u8]> = bytes.split(|&b| b == b'\n').collect();
let line_count = lines.len();
let mut result = Vec::new();
/// Check if the first line in the given slice is foldable.
/// Uses subsequent lines in the slice for lookahead.
pub fn is_line_foldable_in_bytes(lines: &[&[u8]], tab_size: usize) -> bool {
if lines.is_empty() {
return false;
}

for i in 0..line_count {
let (header_indent, header_blank) = slice_indent(lines[i], tab_size);
if header_blank {
continue;
}
let (header_indent, header_blank) = slice_indent(lines[0], tab_size);
if header_blank {
return false;
}

// Find next non-blank line within lookahead.
let limit = line_count.min(i + 1 + max_lookahead);
let mut next = i + 1;
while next < limit {
let (_, blank) = slice_indent(lines[next], tab_size);
if !blank {
break;
}
next += 1;
}
if next >= limit {
continue;
// Find next non-blank line within the provided lines.
let mut next = 1;
while next < lines.len() {
let (_, blank) = slice_indent(lines[next], tab_size);
if !blank {
break;
}
next += 1;
}

let (next_indent, _) = slice_indent(lines[next], tab_size);
if next_indent > header_indent {
result.push(i);
}
if next >= lines.len() {
return false;
}

result
let (next_indent, _) = slice_indent(lines[next], tab_size);
next_indent > header_indent
}

/// Byte-based fold-end search for a single header line.
Expand Down Expand Up @@ -519,44 +500,21 @@ pub mod indent_folding {
}

#[test]
fn test_foldable_lines_basic() {
let text = b"fn main() {\n println!();\n}\n";
let foldable = foldable_lines_in_bytes(text, 4, 50);
assert_eq!(foldable, vec![0]); // line 0 is foldable
}

#[test]
fn test_foldable_lines_nested() {
let text = b"fn main() {\n if true {\n x();\n }\n}\n";
let foldable = foldable_lines_in_bytes(text, 4, 50);
assert_eq!(foldable, vec![0, 1]); // both fn and if are foldable
}

#[test]
fn test_foldable_lines_not_foldable() {
let text = b"line1\nline2\nline3\n";
let foldable = foldable_lines_in_bytes(text, 4, 50);
assert!(foldable.is_empty());
fn test_is_line_foldable_basic() {
let lines: Vec<&[u8]> = vec![b"fn main() {", b" println!();", b"}"];
assert!(is_line_foldable_in_bytes(&lines, 4));
}

#[test]
fn test_foldable_lines_blank_lines_skipped() {
// Blank line between header and indented line should still be foldable
let text = b"fn main() {\n\n println!();\n}\n";
let foldable = foldable_lines_in_bytes(text, 4, 50);
assert_eq!(foldable, vec![0]);
fn test_is_line_foldable_not_foldable() {
let lines: Vec<&[u8]> = vec![b"line1", b"line2", b"line3"];
assert!(!is_line_foldable_in_bytes(&lines, 4));
}

#[test]
fn test_foldable_lines_max_lookahead() {
// With max_lookahead=1, a blank line between header and content means
// the lookahead can't reach the indented line.
let text = b"fn main() {\n\n\n println!();\n}\n";
let foldable_short = foldable_lines_in_bytes(text, 4, 1);
assert!(foldable_short.is_empty());

let foldable_long = foldable_lines_in_bytes(text, 4, 50);
assert_eq!(foldable_long, vec![0]);
fn test_is_line_foldable_blank_lines_skipped() {
let lines: Vec<&[u8]> = vec![b"fn main() {", b"", b" println!();", b"}"];
assert!(is_line_foldable_in_bytes(&lines, 4));
}
}
}
92 changes: 56 additions & 36 deletions crates/fresh-editor/src/view/ui/split_rendering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3321,6 +3321,7 @@ impl SplitRenderer {

ViewLine {
text,
source_start_byte: None,
// Per-character data: all None - no source mapping (this is injected content)
char_source_bytes: vec![None; len],
// All have the virtual text's style
Expand Down Expand Up @@ -4521,6 +4522,7 @@ impl SplitRenderer {
highlight_context_bytes: usize,
view_mode: &ViewMode,
diagnostics_inline_text: bool,
view_lines: &[ViewLine],
) -> DecorationContext {
use crate::view::folding::indent_folding;

Expand Down Expand Up @@ -4670,8 +4672,7 @@ impl SplitRenderer {
line_indicators.entry(key).or_insert(diff_ind);
}

let fold_indicators =
Self::fold_indicators_for_viewport(state, folds, viewport_start, viewport_end);
let fold_indicators = Self::fold_indicators_for_viewport(state, folds, view_lines);

DecorationContext {
highlight_spans,
Expand All @@ -4688,8 +4689,7 @@ impl SplitRenderer {
fn fold_indicators_for_viewport(
state: &EditorState,
folds: &FoldManager,
viewport_start: usize,
viewport_end: usize,
view_lines: &[ViewLine],
) -> BTreeMap<usize, FoldIndicator> {
let mut indicators = BTreeMap::new();

Expand All @@ -4699,33 +4699,54 @@ impl SplitRenderer {
}

if !state.folding_ranges.is_empty() {
// Use LSP-provided folding ranges — key by line-start byte
// Use LSP-provided folding ranges.
// Filter to only ranges that start on one of our visible view lines.
let visible_starts: HashSet<usize> = view_lines
.iter()
.filter_map(|l| l.source_start_byte)
.collect();

for range in &state.folding_ranges {
let start_line = range.start_line as usize;
let end_line = range.end_line as usize;
if end_line <= start_line {
continue;
}
if let Some(line_byte) = state.buffer.line_start_offset(start_line) {
indicators
.entry(line_byte)
.or_insert(FoldIndicator { collapsed: false });
if visible_starts.contains(&line_byte) {
indicators
.entry(line_byte)
.or_insert(FoldIndicator { collapsed: false });
}
}
}
} else {
// Indent-based fold detection on viewport bytes — key by absolute byte offset
use crate::view::folding::indent_folding;
let tab_size = state.buffer_settings.tab_size;
let max_lookahead = crate::config::INDENT_FOLD_INDICATOR_MAX_SCAN;
let bytes = state.buffer.slice_bytes(viewport_start..viewport_end);
if !bytes.is_empty() {
let foldable =
indent_folding::foldable_lines_in_bytes(&bytes, tab_size, max_lookahead);
for line_idx in foldable {
let byte_off = Self::byte_offset_of_line_in_bytes(&bytes, line_idx);
indicators
.entry(viewport_start + byte_off)
.or_insert(FoldIndicator { collapsed: false });

// Iterate through ONLY the visible view lines. This automatically ignores
// thousands of lines hidden by collapsed folds.
for (i, view_line) in view_lines.iter().enumerate() {
// Only source lines (not wrapped continuations) can be fold headers
if let Some(line_start_byte) = view_line.source_start_byte {
if view_line.line_start.is_continuation() {
continue;
}

// Check if this line is foldable by looking at subsequent visible lines
let mut subsequent_lines = Vec::new();
let lookahead_limit = (i + 1 + max_lookahead).min(view_lines.len());
for j in i..lookahead_limit {
subsequent_lines.push(view_lines[j].text.as_bytes());
}

if indent_folding::is_line_foldable_in_bytes(&subsequent_lines, tab_size) {
indicators
.entry(line_start_byte)
.or_insert(FoldIndicator { collapsed: false });
}
}
}
}
Expand Down Expand Up @@ -4796,22 +4817,6 @@ impl SplitRenderer {
indicators
}

/// Given a byte slice, return the byte offset of line N (0-indexed)
/// within that slice.
fn byte_offset_of_line_in_bytes(bytes: &[u8], line_idx: usize) -> usize {
let mut current_line = 0;
for (i, &b) in bytes.iter().enumerate() {
if current_line == line_idx {
return i;
}
if b == b'\n' {
current_line += 1;
}
}
// If we exhausted the bytes without reaching the line, return end
bytes.len()
}

// semantic token colors are mapped when overlays are created

fn calculate_viewport_end(
Expand Down Expand Up @@ -4939,6 +4944,7 @@ impl SplitRenderer {
static EMPTY_LINE: std::sync::OnceLock<ViewLine> = std::sync::OnceLock::new();
EMPTY_LINE.get_or_init(|| ViewLine {
text: String::new(),
source_start_byte: None,
char_source_bytes: Vec::new(),
char_styles: Vec::new(),
char_visual_cols: Vec::new(),
Expand Down Expand Up @@ -6319,6 +6325,7 @@ impl SplitRenderer {
highlight_context_bytes,
&view_mode,
diagnostics_inline_text,
&view_data.lines,
);

let calculated_offset = viewport.top_view_line_offset;
Expand Down Expand Up @@ -6969,6 +6976,7 @@ mod tests {
100_000, // default highlight context bytes
&ViewMode::Source, // Tests use source mode
false, // inline diagnostics off for test
&[],
);

let mut dummy_theme_map = Vec::new();
Expand Down Expand Up @@ -7074,13 +7082,24 @@ mod tests {
let mut folds = FoldManager::new();
folds.add(&mut state.marker_list, start, end, None);

let indicators =
SplitRenderer::fold_indicators_for_viewport(&state, &folds, 0, state.buffer.len());
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 = SplitRenderer::fold_indicators_for_viewport(&state, &folds, &view_lines);

// Collapsed fold: header is line 0 (byte 0)
assert_eq!(indicators.get(&0).map(|i| i.collapsed), Some(true));
// LSP range starting at line 1 (byte 2, since "a\n" is 2 bytes)
let line1_byte = state.buffer.line_start_offset(1).unwrap();
assert_eq!(
indicators.get(&line1_byte).map(|i| i.collapsed),
Some(false)
Expand Down Expand Up @@ -8525,6 +8544,7 @@ mod tests {
100_000,
&ViewMode::Source,
false,
&[],
);

SplitRenderer::render_view_lines(LineRenderInput {
Expand Down
6 changes: 6 additions & 0 deletions crates/fresh-editor/src/view/ui/view_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ pub struct ViewLine {
/// The display text for this line (tabs expanded to spaces, etc.)
pub text: String,

/// Absolute source byte offset of the start of this line (if it has one)
pub source_start_byte: Option<usize>,

// === Per-CHARACTER mappings (indexed by char position in text) ===
/// Source byte offset for each character
/// Length == text.chars().count()
Expand Down Expand Up @@ -210,8 +213,10 @@ impl<'a> Iterator for ViewLineIterator<'a> {
if self.at_buffer_end && matches!(self.next_line_start, LineStart::AfterSourceNewline) {
// Flip to Beginning so the *next* call returns None.
self.next_line_start = LineStart::Beginning;
let last_source_byte = self.tokens.last().and_then(|t| t.source_offset);
return Some(ViewLine {
text: String::new(),
source_start_byte: last_source_byte.map(|s| s + 1),
char_source_bytes: vec![],
char_styles: vec![],
char_visual_cols: vec![],
Expand Down Expand Up @@ -452,6 +457,7 @@ impl<'a> Iterator for ViewLineIterator<'a> {

Some(ViewLine {
text,
source_start_byte: char_source_bytes.iter().find_map(|s| *s),
char_source_bytes,
char_styles,
char_visual_cols,
Expand Down
Loading