diff --git a/ui/qml/OutlineView.qml b/ui/qml/OutlineView.qml index d0e5527..ef630e4 100644 --- a/ui/qml/OutlineView.qml +++ b/ui/qml/OutlineView.qml @@ -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,13 +230,22 @@ 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]; let trimmed = line.trim(); @@ -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 {