Skip to content

Commit 8fb8296

Browse files
committed
fix: Semantic minimap/outline highlight accuracy - use byte offsets matching search behavior
1 parent 2a86d0e commit 8fb8296

6 files changed

Lines changed: 283 additions & 97 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3838
- **Text wrapping support** - Handle wrapped lines correctly by using actual text rect width for measurement.
3939
- **Bold font measurement** - Use bold font for measurement when content starts with bold markers for better accuracy.
4040

41+
#### Scroll Navigation Accuracy
42+
- **Unified scroll calculation** - Single function for all scroll-to-line operations (find, search-in-files, outline, minimap) ensuring consistent positioning.
43+
- **Fixed off-by-one errors** - Consistent 0-indexed vs 1-indexed line number handling across all navigation functions.
44+
- **Fresh line height** - Ensure actual rendered line height is used instead of stale/default values when calculating scroll positions.
45+
- **Large file accuracy** - Scroll navigation now works correctly in files with 3000+ lines; previously target lines could be hundreds of pixels off or completely out of view.
46+
- **Semantic minimap highlight fix** - Fixed highlight offset when clicking items in semantic minimap/outline panel. The highlight now correctly marks the target line by using byte offsets (matching search behavior) instead of character offsets.
47+
4148
#### Settings & UX
4249
- **Session restore option** - New setting to disable tab restoration on startup. When disabled, app starts with a single empty tab instead of restoring previous session.
4350

ROADMAP.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,11 @@ New Ferrite logo and icon set.
9797

9898
---
9999

100-
### v0.2.5.1 (Released) - Memory, Encoding & Polish
100+
### v0.2.5.1 (Released) - Memory, Encoding & Accuracy
101101

102102
> **Status:** Released (2026-01-17)
103103
104-
Point release focusing on memory optimization, multi-encoding support, cursor positioning improvements, and UX polish.
104+
Point release focusing on memory optimization, multi-encoding support, cursor positioning improvements, scroll navigation accuracy, and UX polish.
105105

106106
#### Multi-Encoding File Support
107107
- [x] **Encoding detection** - Auto-detect file encoding on open using `encoding_rs` + `chardetng` crates
@@ -115,7 +115,14 @@ Point release focusing on memory optimization, multi-encoding support, cursor po
115115
- [x] **Text wrapping support** - Handle wrapped lines correctly by using actual text rect width for measurement
116116
- [x] **Bold font measurement** - Use bold font for measurement when content starts with bold markers
117117

118-
> **Known Limitation:** Cursor positioning is best-effort accurate. Lines with mixed formatting (bold + regular + italic) may have slight drift on longer lines due to font width differences. Perfect positioning requires the custom editor widget planned for v0.3.0.
118+
#### Scroll Navigation Accuracy (Critical Fix)
119+
- [x] **Unified scroll calculation** - Single function for all scroll-to-line operations ensuring consistent behavior across find, search-in-files, outline panel, and semantic minimap
120+
- [x] **Fixed off-by-one errors** - Consistent 0-indexed vs 1-indexed line handling in `navigate_to_heading()` and related functions
121+
- [x] **Fresh line height tracking** - Use actual rendered line height instead of stale/default 20.0 value
122+
- [x] **Large file navigation** - Fixed scroll accuracy in files with 3000+ lines where targets around line 2000 could be 1000+ pixels off
123+
- [x] **Semantic minimap highlight fix** - Fixed highlight offset when clicking outline/minimap items; uses byte offsets (matching search) instead of character offsets
124+
125+
> **Known Limitation:** Cursor positioning and scroll accuracy are best-effort within egui's constraints. Lines with mixed formatting may have slight drift on longer lines due to font width differences. Perfect positioning requires the custom editor widget planned for v0.3.0.
119126
120127
#### Internationalization
121128
- [x] **Language selector** - Settings option to choose UI language
@@ -324,7 +331,7 @@ Replace egui's `TextEdit` with a custom `FerriteEditor` widget to unblock advanc
324331
- [ ] **Code folding with text hiding** - Actually collapse regions visually
325332

326333
#### 4. Semantic Minimap Polish
327-
- [ ] **Scroll position accuracy** - Fix navigation centering for variable line heights, word wrap, and editor padding (deferred from v0.2.5)
334+
- [ ] **Pixel-perfect scroll positioning** - With custom editor widget, use actual galley coordinates for perfect navigation centering (basic fix shipped in v0.2.5.1; v0.3.0 provides full solution via FerriteEditor)
328335

329336
#### 5. Markdown Enhancements
330337
- [ ] **Wikilinks support** ([#1](https://github.com/OlaProeis/Ferrite/issues/1)) - `[[wikilinks]]` syntax with auto-completion

src/app.rs

Lines changed: 89 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -5376,6 +5376,9 @@ impl FerriteApp {
53765376
// CSV/TSV files DO support split mode (raw text + table view)
53775377
let skip_split_mode = file_type.is_structured();
53785378

5379+
// Track if we need to set App-level pending_scroll_to_line for Raw mode
5380+
let mut raw_mode_scroll_to_line: Option<usize> = None;
5381+
53795382
if let Some(tab) = self.state.active_tab_mut() {
53805383
let old_mode = tab.view_mode;
53815384
let current_scroll = tab.scroll_offset;
@@ -5430,7 +5433,7 @@ impl FerriteApp {
54305433
1
54315434
};
54325435

5433-
// Store for line-based lookup after render
5436+
// Store for line-based lookup after render (Rendered mode uses tab field)
54345437
tab.pending_scroll_to_line = Some(topmost_line);
54355438
debug!(
54365439
"Sync scroll Raw→Rendered: scroll={} / line_height={:.1} → line {}",
@@ -5444,13 +5447,13 @@ impl FerriteApp {
54445447
current_scroll,
54455448
content_height,
54465449
) {
5447-
// Calculate target scroll in Raw mode
5448-
let line_height = tab.raw_line_height.max(20.0);
5449-
let target_scroll = (source_line.saturating_sub(1) as f32) * line_height;
5450-
tab.pending_scroll_offset = Some(target_scroll);
5450+
// Raw mode EditorWidget uses App-level pending_scroll_to_line
5451+
// (not tab field), so we store it for setting after borrow ends.
5452+
// source_line is 1-indexed from the mapping.
5453+
raw_mode_scroll_to_line = Some(source_line);
54515454
debug!(
5452-
"Sync scroll Rendered→Raw: scroll={} → line {} → raw_offset={:.1}",
5453-
current_scroll, source_line, target_scroll
5455+
"Sync scroll Rendered→Raw: scroll={} → line {} (will use App-level pending_scroll_to_line)",
5456+
current_scroll, source_line
54545457
);
54555458
} else {
54565459
// Fallback to percentage if no mappings
@@ -5474,6 +5477,12 @@ impl FerriteApp {
54745477
// Mark settings dirty to save per-tab view mode on exit
54755478
self.state.mark_settings_dirty();
54765479
}
5480+
5481+
// Set App-level pending_scroll_to_line AFTER releasing mutable borrow.
5482+
// This is used by Raw mode EditorWidget which reads from self.pending_scroll_to_line.
5483+
if let Some(line) = raw_mode_scroll_to_line {
5484+
self.pending_scroll_to_line = Some(line);
5485+
}
54775486
}
54785487

54795488
/// Find the rendered Y position for a given source line using interpolated line mappings.
@@ -6377,88 +6386,49 @@ impl FerriteApp {
63776386
/// 2. Applying transient highlight to make the heading visible
63786387
/// 3. Positioning the cursor at the heading
63796388
fn navigate_to_heading(&mut self, nav: HeadingNavRequest) {
6380-
// First, find the heading range without holding mutable borrow
6381-
// Note: content.find() returns BYTE offsets, we need to convert to CHAR offsets
6389+
// Find the line range using the line number from OutlineItem
6390+
// This is the most reliable approach since line numbers are always correct
63826391
let found_range = if let Some(tab) = self.state.active_tab() {
63836392
let content = &tab.content;
63846393

6385-
// Try to find the heading by text if available
6386-
if let (Some(title), Some(level)) = (&nav.title, nav.level) {
6387-
// Build the markdown heading pattern: "# Title" or "## Title" etc.
6388-
let hashes = "#".repeat(level as usize);
6389-
let pattern = format!("{} {}", hashes, title);
6390-
6391-
// Search for exact pattern first - find() returns byte offset
6392-
if let Some(byte_start) = content.find(&pattern) {
6393-
let byte_end = byte_start + pattern.len();
6394-
// Convert byte offsets to character offsets for egui
6395-
let char_start = Self::byte_to_char_offset(content, byte_start);
6396-
let char_end = Self::byte_to_char_offset(content, byte_end);
6397-
Some((char_start, char_end))
6398-
} else {
6399-
// Try case-insensitive search around the expected line
6400-
// This already returns character offsets
6401-
Self::find_heading_near_line(content, title, level, nav.line)
6402-
}
6403-
} else if let Some(char_offset) = nav.char_offset {
6404-
// nav.char_offset is already a character offset from OutlineItem
6405-
// Find end of line starting from this position
6406-
let mut char_count = 0;
6407-
let mut line_end_char = char_offset;
6408-
for (i, ch) in content.chars().enumerate() {
6409-
if i >= char_offset {
6410-
if ch == '\n' {
6411-
line_end_char = i;
6412-
break;
6413-
}
6414-
char_count += 1;
6415-
}
6416-
if i > char_offset + 200 {
6417-
// Safety limit
6418-
line_end_char = i;
6419-
break;
6420-
}
6421-
}
6422-
if char_count > 0 {
6423-
line_end_char = char_offset + char_count;
6424-
}
6425-
// If we didn't find newline, use end of content
6426-
if line_end_char == char_offset {
6427-
line_end_char = content.chars().count();
6428-
}
6429-
Some((char_offset, line_end_char))
6430-
} else {
6431-
None
6432-
}
6394+
// Find the byte range for the target line (nav.line is 1-indexed)
6395+
Self::find_line_byte_range(content, nav.line)
64336396
} else {
64346397
None
64356398
};
64366399

6437-
// Now apply navigation with mutable borrow
6438-
if let Some(tab) = self.state.active_tab_mut() {
6400+
// Apply navigation and calculate target line (1-indexed for scroll)
6401+
let target_line_1indexed = if let Some(tab) = self.state.active_tab_mut() {
64396402
if let Some((char_start, char_end)) = found_range {
64406403
// Set transient highlight for the heading line (expects char offsets)
64416404
tab.set_transient_highlight(char_start, char_end);
64426405

6443-
// Calculate line and column from character offset
6406+
// Calculate line and column from character offset (0-indexed)
64446407
let (target_line, _) = Self::offset_to_line_col(&tab.content, char_start);
64456408
tab.cursor_position = (target_line, 0);
64466409

6447-
// Calculate scroll offset to center the heading
6448-
let line_height = tab.raw_line_height.max(1.0);
6449-
let viewport_height = tab.viewport_height;
6450-
let target_scroll = (target_line as f32 * line_height) - (viewport_height / 3.0);
6451-
tab.pending_scroll_offset = Some(target_scroll.max(0.0));
6452-
6410+
let line_1indexed = target_line + 1;
64536411
debug!(
64546412
"Navigated to heading at char offset {}-{}, line {}",
6455-
char_start, char_end, target_line + 1
6413+
char_start, char_end, line_1indexed
64566414
);
6415+
Some(line_1indexed)
64576416
} else {
6458-
// Fall back to basic line navigation
6459-
self.pending_scroll_to_line = Some(nav.line);
6417+
// Fall back to basic line navigation using nav.line (already 1-indexed)
64606418
tab.cursor_position = (nav.line.saturating_sub(1), 0);
6419+
debug!("Navigated to heading via fallback, line {}", nav.line);
6420+
Some(nav.line)
64616421
}
6422+
} else {
6423+
None
6424+
};
6425+
6426+
// Set pending scroll AFTER releasing the mutable borrow.
6427+
// Use App-level pending_scroll_to_line so EditorWidget calculates
6428+
// scroll offset with fresh line height from ui.fonts().
6429+
// This is more accurate than using potentially stale raw_line_height.
6430+
if let Some(line) = target_line_1indexed {
6431+
self.pending_scroll_to_line = Some(line);
64626432
}
64636433
}
64646434

@@ -6508,6 +6478,56 @@ impl FerriteApp {
65086478
None
65096479
}
65106480

6481+
/// Find the BYTE range (start, end) for a specific line number.
6482+
///
6483+
/// # Arguments
6484+
/// * `content` - The text content
6485+
/// * `line_num` - The line number (1-indexed)
6486+
///
6487+
/// # Returns
6488+
/// The byte offset range (start, end) for that line, or None if line doesn't exist.
6489+
/// Note: Returns BYTE offsets because set_transient_highlight expects bytes.
6490+
fn find_line_byte_range(content: &str, line_num: usize) -> Option<(usize, usize)> {
6491+
if line_num == 0 {
6492+
return None;
6493+
}
6494+
6495+
let target_idx = line_num - 1; // Convert to 0-indexed
6496+
6497+
// Simple approach: find the byte position by scanning the actual bytes
6498+
let bytes = content.as_bytes();
6499+
let mut line_start = 0;
6500+
let mut current_line = 0;
6501+
6502+
for (i, &byte) in bytes.iter().enumerate() {
6503+
if current_line == target_idx {
6504+
// Found the start of our target line, now find its end
6505+
let mut line_end = i;
6506+
for j in i..bytes.len() {
6507+
if bytes[j] == b'\n' {
6508+
// Don't include \r if present
6509+
line_end = if j > 0 && bytes[j - 1] == b'\r' { j - 1 } else { j };
6510+
break;
6511+
}
6512+
line_end = j + 1;
6513+
}
6514+
return Some((i, line_end));
6515+
}
6516+
6517+
if byte == b'\n' {
6518+
current_line += 1;
6519+
line_start = i + 1;
6520+
}
6521+
}
6522+
6523+
// Handle last line (no trailing newline)
6524+
if current_line == target_idx {
6525+
return Some((line_start, bytes.len()));
6526+
}
6527+
6528+
None
6529+
}
6530+
65116531
/// Convert a byte offset to character offset.
65126532
///
65136533
/// This is needed because `String::find()` returns byte offsets, but egui's

src/editor/outline.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,18 @@ impl DocumentOutline {
351351
/// A `DocumentOutline` containing all extracted items.
352352
pub fn extract_outline(text: &str) -> DocumentOutline {
353353
let mut items = Vec::new();
354-
let mut char_offset = 0;
354+
355+
// Build a map of line number (0-indexed) to character offset
356+
// This is more accurate than reconstructing offsets from lines() iteration
357+
let line_offsets: Vec<usize> = {
358+
let mut offsets = vec![0]; // Line 0 starts at char 0
359+
for (i, ch) in text.chars().enumerate() {
360+
if ch == '\n' {
361+
offsets.push(i + 1); // Next line starts after the \n
362+
}
363+
}
364+
offsets
365+
};
355366

356367
// State tracking
357368
let mut in_code_block = false;
@@ -368,6 +379,9 @@ pub fn extract_outline(text: &str) -> DocumentOutline {
368379
for (line_idx, line) in text.lines().enumerate() {
369380
let line_num = line_idx + 1; // 1-indexed
370381
let trimmed = line.trim();
382+
383+
// Get the character offset for this line from our pre-built map
384+
let char_offset = line_offsets.get(line_idx).copied().unwrap_or(0);
371385

372386
// Handle code block boundaries
373387
if trimmed.starts_with("```") {
@@ -479,9 +493,8 @@ pub fn extract_outline(text: &str) -> DocumentOutline {
479493
}
480494
}
481495

482-
// Track CHARACTER offset (not byte offset!) including newline
483-
// Use chars().count() instead of len() to correctly handle UTF-8
484-
char_offset += line.chars().count() + 1;
496+
// Note: char_offset is now looked up from line_offsets map at loop start,
497+
// so no manual tracking needed here.
485498
}
486499

487500
// Finalize any remaining content blocks at end of document

0 commit comments

Comments
 (0)