Scroll to the body segment that contains a link offset

Link reveal previously scrolled only to the section top (offset > 0 just
chose Beginning vs Contain). Thread the body offset through to the
rendered segment so the view scrolls to the matched segment, keeping the
existing segmented body rendering (text/code/table/list).

- The bodyParts builder now records each segment's startLine (the body
  line index where it begins) for text, list, code, and table parts,
  tracked through the line-categorizing loop and pushText.
- A new delegate function offsetToY(bodyOffset) converts the offset to a
  body line, finds the last segment whose startLine <= that line, and
  returns that segment's y relative to the delegate via mapToItem.
- On sectionRevealed with offset > 0, position the row at the viewport
  top (which realizes the delegate), then nudge contentY by offsetToY so
  the matched segment sits at the top, clamped to the content bounds.

The offset->line->segment selection was verified with a standalone
harness replicating the builder and selection logic: for a body mixing
intro text, a code block, a paragraph, a table, trailing text, and a
list, every line maps to the segment that actually contains it. The
final pixel scroll uses standard Qt Quick coordinate mapping; it needs a
rendered scene to verify visually, which is not possible headlessly.
Granularity is now segment-precise; intra-segment line precision is not
attempted (text segments are Labels without glyph positioning).
This commit is contained in:
Levi Neely 2026-10-02 12:26:09 +02:00
parent de43a3721d
commit bfc0c80d09
1 changed files with 84 additions and 20 deletions

View File

@ -81,18 +81,31 @@ ListView {
property int flashRow: -1
// When the model reveals a section, select it, scroll it into view, and
// flash it briefly. `bodyOffset` is a byte offset into the section's body:
// when the match lies inside the body (offset > 0), position the row at the
// top of the view so as much of the body as possible is visible; otherwise
// just ensure the row is contained.
// flash it briefly. `bodyOffset` is a byte offset into the section's body.
// When the match lies inside the body (offset > 0), position the row at the
// top of the view, then scroll further so the body segment containing the
// offset sits at the top (segment-precise). Otherwise just ensure the row
// is contained.
Connections {
target: docModel
function onSectionRevealed(row, bodyOffset) {
if (row < 0)
return
outline.currentIndex = row
outline.positionViewAtIndex(
row, bodyOffset > 0 ? ListView.Beginning : ListView.Contain)
if (bodyOffset > 0) {
// Bring the row's top to the viewport top first; this also
// realizes the delegate so its segments can be measured.
outline.positionViewAtIndex(row, ListView.Beginning)
let item = outline.itemAtIndex(row)
if (item && item.offsetToY) {
let extra = item.offsetToY(bodyOffset)
let maxY = Math.max(outline.originY,
outline.contentHeight - outline.height)
outline.contentY = Math.min(outline.contentY + extra, maxY)
}
} else {
outline.positionViewAtIndex(row, ListView.Contain)
}
outline.flashRow = row
flashTimer.restart()
}
@ -169,34 +182,42 @@ ListView {
// dedicated "list" parts so they render with bullets/checkboxes.
// `cbCounter` is a single-element array used as a mutable counter so
// checkbox ordinals are stable across the whole section body.
function pushText(text, cbCounter) {
// `baseLine` is the body line index of the first line of `text`,
// so each emitted part records the body line where it begins
// (used for char/offset-precise scrolling).
function pushText(text, cbCounter, baseLine) {
if (!text) return;
let lines = text.split('\n');
let buf = [];
let items = [];
let bufStart = -1;
let itemsStart = -1;
function flushBuf() {
if (buf.length > 0) {
let t = buf.join('\n').trim();
if (t) parts.push({type: "text", content: t});
if (t) parts.push({type: "text", content: t, startLine: bufStart});
buf = [];
}
}
function flushItems() {
if (items.length > 0) {
parts.push({type: "list", items: items});
parts.push({type: "list", items: items, startLine: itemsStart});
items = [];
}
}
for (let ln of lines) {
for (let li = 0; li < lines.length; li++) {
let ln = lines[li];
let item = parseListItem(ln);
if (item) {
flushBuf();
if (items.length === 0) itemsStart = baseLine + li;
// Assign a stable ordinal to checkbox-bearing items.
item.checkboxOrdinal = (item.checkbox !== "")
? cbCounter[0]++ : -1;
items.push(item);
} else {
flushItems();
if (buf.length === 0) bufStart = baseLine + li;
buf.push(ln);
}
}
@ -209,12 +230,21 @@ ListView {
let lines = body.split('\n');
let currentText = [];
let textRunStart = -1; // body line index where currentText began
let inCode = false;
let codeLang = "";
let codeLines = [];
let codeStart = -1; // body line index of the #+BEGIN_SRC
let codeCounter = 0;
let inTable = false;
let tableLines = [];
let tableStart = -1; // body line index of the first table row
// Append a line to the pending text run, recording its start line.
function pushCurrentText(i) {
if (currentText.length === 0) textRunStart = i;
currentText.push(lines[i]);
}
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
@ -224,14 +254,15 @@ ListView {
if (!inCode && !inTable && trimmed.toUpperCase().startsWith("#+BEGIN_SRC")) {
// Flush pending text
if (currentText.length > 0) {
pushText(currentText.join('\n').trim(), cbCounter);
pushText(currentText.join('\n').trim(), cbCounter, textRunStart);
currentText = [];
}
inCode = true;
codeStart = i;
codeLang = trimmed.substring(11).trim().split(/\s+/)[0] || "";
codeLines = [];
} else if (inCode && trimmed.toUpperCase() === "#+END_SRC") {
parts.push({type: "code", language: codeLang, content: codeLines.join('\n'), codeIndex: codeCounter});
parts.push({type: "code", language: codeLang, content: codeLines.join('\n'), codeIndex: codeCounter, startLine: codeStart});
codeCounter++;
inCode = false;
codeLang = "";
@ -243,10 +274,11 @@ ListView {
else if (!inTable && trimmed.startsWith("|")) {
// Flush pending text
if (currentText.length > 0) {
pushText(currentText.join('\n').trim(), cbCounter);
pushText(currentText.join('\n').trim(), cbCounter, textRunStart);
currentText = [];
}
inTable = true;
tableStart = i;
tableLines = [trimmed];
} else if (inTable && trimmed.startsWith("|")) {
tableLines.push(trimmed);
@ -264,21 +296,21 @@ ListView {
rows.push(cells);
}
}
parts.push({type: "table", rows: rows, hasHeader: hasHeader});
parts.push({type: "table", rows: rows, hasHeader: hasHeader, startLine: tableStart});
tableLines = [];
inTable = false;
// Process current line as text if not empty
if (trimmed) currentText.push(line);
if (trimmed) pushCurrentText(i);
}
} else {
currentText.push(line);
pushCurrentText(i);
}
}
// Flush remaining
if (inCode && codeLines.length > 0) {
parts.push({type: "code", language: codeLang, content: codeLines.join('\n'), codeIndex: codeCounter});
parts.push({type: "code", language: codeLang, content: codeLines.join('\n'), codeIndex: codeCounter, startLine: codeStart});
codeCounter++;
} else if (inTable && tableLines.length > 0) {
let rows = [];
@ -291,14 +323,45 @@ ListView {
rows.push(cells);
}
}
parts.push({type: "table", rows: rows, hasHeader: hasHeader});
parts.push({type: "table", rows: rows, hasHeader: hasHeader, startLine: tableStart});
} else if (currentText.length > 0) {
pushText(currentText.join('\n').trim(), cbCounter);
pushText(currentText.join('\n').trim(), cbCounter, textRunStart);
}
return parts;
}
// Map a body byte offset to a y position (relative to this delegate's
// top) by locating the body segment that contains the offset's line and
// returning that segment's rendered y. This keeps the segmented
// rendering while scrolling precisely to the matched segment rather
// than only the section top. Returns 0 when there is no better target.
function offsetToY(bodyOffset) {
if (bodyOffset <= 0 || bodyParts.length === 0)
return 0;
// Target line = number of newlines before the offset in the body.
let body = sectionBody;
let clamped = Math.min(bodyOffset, body.length);
let targetLine = 0;
for (let k = 0; k < clamped; k++)
if (body.charCodeAt(k) === 10) targetLine++;
// Find the last part whose startLine <= targetLine (parts are in
// document order with non-decreasing startLine).
let pIdx = -1;
for (let p = 0; p < bodyParts.length; p++) {
let sl = bodyParts[p].startLine;
if (sl === undefined || sl < 0) continue;
if (sl <= targetLine) pIdx = p; else break;
}
if (pIdx < 0)
return 0;
let item = bodyRepeater.itemAt(pIdx);
if (!item)
return 0;
// y of the segment relative to this delegate.
return item.mapToItem(del, 0, 0).y;
}
MouseArea {
anchors.fill: parent
onClicked: outline.currentIndex = del.index
@ -489,6 +552,7 @@ ListView {
spacing: 4
Repeater {
id: bodyRepeater
model: del.bodyParts
delegate: Loader {