Ferrite keeps document position aligned between Raw, Rendered, and Split (raw + preview side-by-side) views. Two mechanisms share the same hybrid top/bottom/middle strategy but run at different times.
| Mechanism | When | Setting |
|---|---|---|
| Mode-toggle sync | Ctrl+E (Raw ↔ Rendered, not involving Split) | sync_scroll_enabled |
| Split-view live sync | While scrolling in Split on markdown files | Same setting + optional 2-way |
Default: sync_scroll_enabled = false, sync_scroll_bidirectional = true (when sync is turned on).
- Settings → Preview → Sync scroll — global preference (also ribbon Sync Scroll toggle).
- Split view — footer under the semantic minimap (between editor and preview):
- Sync — master on/off for live split sync (persists to settings).
- 2-way — when Sync is on: bidirectional (default) vs raw → rendered only.
i18n keys: settings.preview.sync_scroll, split_sync_scroll, split_sync_bidirectional in locales/en.yaml.
Used for mode toggle and split idle snap:
Within 5px of top → scroll offset 0 (both panes / target mode)
Within 5px of bottom → max scroll (or ratio 1.0 on mode toggle)
Otherwise (middle) → content-based mapping (line + fraction), not %
Why not percentage-only? Code blocks and Mermaid are much taller in preview than in raw line count; a 50% scroll ratio shows different content. Line-based mapping with interpolation inside blocks keeps the same source line visible across modes.
Constants: SCROLL_BOUNDARY_PX = 5.0 in src/preview/sync_scroll.rs.
Entry: FerriteApp::handle_toggle_view_mode() in src/app/navigation.rs.
Only runs when sync_scroll_enabled and switching between Raw and Rendered (not Split).
| Field | Purpose |
|---|---|
pending_scroll_offset |
Pixel offset to apply next frame |
pending_scroll_ratio |
0.0–1.0 (e.g. bottom = 1.0), converted after layout |
pending_scroll_to_line |
1-based line for Raw→Rendered lookup |
pending_scroll_anchor |
(line, fraction) for split / rendered→raw |
rendered_line_mappings |
(start_line, end_line, rendered_y) per block |
raw_line_height |
From Ferrite editor for raw scroll math |
Tab::clear_sync_pending_scroll() clears offset, anchor, ratio, and pending_scroll_to_line (not app-level outline pending_scroll_to_line).
- Render, build
rendered_line_mappings, convertpending_scroll_to_line→pending_scroll_offsetvia interpolation. - Apply
pending_scroll_offsetonScrollArea::vertical_scroll_offset.
find_rendered_y_for_line_interpolated / find_source_line_for_rendered_y_interpolated in central_panel.rs — within a block spanning lines 100–200, line 150 maps to ~50% of that block’s rendered height, not only the block top.
Entry: src/app/central_panel.rs (markdown split layout).
Gated: settings.sync_scroll_enabled && markdown file in split.
User scrolls (wheel, scrollbar drag, or keys)
→ note_scroll_activity() detects offset delta (≥2px), not wheel-only
→ master pane: Raw or Rendered (mouse hover + larger delta)
→ store_raw_anchor(line, fraction) or store_preview_y(y)
~120ms idle (SPLIT_SCROLL_IDLE)
→ single programmatic snap to other pane
→ top/bottom: snap to 0 or max scroll
→ middle: source_anchor_to_preview_y / preview_y_to_source_anchor
→ mark_programmatic() (~80ms) to ignore echo
Sync OFF
→ clear tab pending scroll fields each frame
→ reset SyncScrollState on toggle off
→ no idle snap loop
- Raw:
EditorOutput.scroll_anchor_line+scroll_anchor_fractionfrom FerriteViewState(wrap-aware). - Preview:
LineMappinglist from rendered pass;SyncScrollState::source_anchor_to_preview_y/preview_y_to_source_anchorinsrc/preview/sync_scroll.rs.
sync_scroll_bidirectional (default true):
- On: scrolling preview can move raw (see Split-view scroll delivery below).
- Off: only raw → preview (
tab.pending_scroll_offset). Preview-only scroll setsScrollOrigin::Renderedand clears any stale raw anchor so idle snap does not re-align the preview to an old raw position.
Each pane has its own programmatic scroll channel. Using the wrong field causes visible jumps (e.g. applying raw max scroll to the preview snaps the preview upward on code-block-heavy docs).
| Direction | Region | Mechanism | Applied by |
|---|---|---|---|
| Raw → preview | top / bottom / middle | tab.pending_scroll_offset |
MarkdownEditor::pending_scroll_offset (start of next frame) |
| Preview → raw | top / bottom | SyncScrollState::set_raw_target(y) |
EditorWidget::pending_sync_scroll_offset via get_animated_raw_offset() |
| Preview → raw | middle | tab.pending_scroll_anchor (line, fraction) |
EditorWidget → scroll_to_absolute on anchor line |
Implementation: idle snap in src/app/central_panel.rs after both panes render; constants and helpers in src/preview/sync_scroll.rs.
Regression: with Sync + 2-way on, scroll the preview to the bottom — preview stays at bottom and raw follows; raw → preview bottom unchanged.
When sync is disabled, rendered mode must not apply stale pending_scroll_* from a prior split session or mode switch:
sync_oncaptured beforeactive_tab_mut()incentral_panel.rs.- Pending offset/ratio/line conversion only when
sync_on. - Ribbon or minimap Sync off →
clear_sync_pending_scroll()+SyncScrollState::reset_on_disable().
Rendered preview uses viewport culling (ViewportCullingState in src/markdown/editor.rs). Block heights (especially code blocks) are measured as they enter view; total_height can change after scroll stops.
Height fixup (sync-independent): when total_height changes by >1px and the user is not actively scrolling, the next frame preserves scroll ratio (cur/max_old → ratio*max_new) via temp memory rendered_height_fixup so the viewport does not jump when egui keeps a fixed pixel offset. Fixup is suppressed for 200ms after user scroll input so stopping a wheel/scrollbar drag does not immediately nudge the viewport.
Active scroll input is detected via is_active_scroll_input() — mouse wheel (smooth_scroll_delta) or a decided pointer drag (scrollbar thumb). Simple clicks (e.g. task list checkboxes, links) do not count as scrolling and do not start the cooldown.
Inline-only content edits (task checkbox [ ] ↔ [x], etc.) reuse existing culling layout when top-level block (start_line, end_line) ranges are unchanged, even if content_hash differs. That avoids a bootstrap remeasure frame that would change total_height and shift scroll. See rendered-view-viewport-culling.md and task-list-checkbox.md.
This is separate from sync; it fixes layout remeasure “nudges” mid-document.
| Field | Default | Description |
|---|---|---|
sync_scroll_enabled |
false |
Mode toggle + split live sync |
sync_scroll_bidirectional |
true |
Preview → raw when split sync on |
| File | Role |
|---|---|
src/app/central_panel.rs |
Split layout, idle sync, minimap footer, rendered pending guards |
src/app/navigation.rs |
Mode-toggle hybrid sync |
src/app/mod.rs |
Ribbon toggle, sync_scroll_states cleanup on tab close |
src/preview/sync_scroll.rs |
SyncScrollState, anchors, boundaries, idle/programmatic |
src/editor/minimap.rs |
show_split_sync_footer, SPLIT_SYNC_FOOTER_HEIGHT |
src/editor/widget.rs |
pending_scroll_anchor, pending_sync_scroll_offset, EditorOutput scroll metrics |
src/markdown/editor.rs |
Rendered scroll, culling, height fixup |
src/state.rs |
Tab pending fields, clear_sync_pending_scroll() |
- Soft follow — optional lerp toward target during scroll (smoother than idle-only snap).
- Mapping warmup — delay split sync until all block heights measured (heavy Mermaid docs).
- Long markdown (100+ lines, code blocks): scroll to ~50%, Ctrl+E Raw↔Rendered — same content region visible.
- Top / bottom: within a few px of edge, toggle stays at edge.
- Sync off: toggle resets scroll to top (legacy behavior).
- Enable Sync in minimap footer; scroll raw with wheel and scrollbar — preview follows after brief idle.
- 2-way on: scroll preview — raw follows (middle of document).
- 2-way on: scroll preview to bottom — preview stays at bottom; raw scrolls to its bottom (no upward jump).
- 2-way on: scroll preview to top — both panes at top.
- 2-way off: preview scroll does not move raw.
- Top/bottom of either pane — both panes align to edges (raw → preview and preview → raw).
- Turn Sync off while scrolling preview — no post-scroll snap; no ghost jumps from earlier sync state.
- Code-block-heavy doc, sync off — scroll rendered only; verify no repeated small jumps (height fixup should be minimal).
- Long task list, scroll to middle — toggle several checkboxes; scroll position must stay fixed (no up/down nudge).
- View mode persistence — per-file Raw/Split/Rendered restore
- Rendered viewport culling — block height cache and performance