Make resolve offsets index the rendered /body text

Link resolution reported byte offsets relative to a section's raw
content region (headline end), but the GUI renders /body, which strips
the planning line, property drawer, and surrounding blank lines. An
offset into raw content therefore did not line up with the body the
GUI shows, so scrolling to a link target landed in the wrong place.

Server:
- Extract section_body_text() as a shared helper so section_body() and
  resolve_target() derive offsets from the exact same rendered string.
- Rewrite the dedicated-target, named-element, and plain-text branches
  of resolve_target() to search section_body_text() and report a
  /body-relative offset.
- Replace the affiliated-keyword lookup with find_name_line(), a text
  scan over /body: the parser only attaches #+NAME to some element
  kinds (not tables), so scanning the rendered body is more reliable.
- Drop the now-unused section_content() helper.
- Update tests with concrete /body-relative offset assertions
  (<<target>> at 22, blank-stripped at 5, #+NAME at 44).

GUI:
- sectionRevealed gains a bodyOffset argument, threaded from the
  resolve response through revealSection into the QML Connections.
- OutlineView positions the row at Beginning when the match lies inside
  the body (offset > 0) so the body is visible, else just Contain.

Scroll granularity is section-top: the row is scrolled into view, not
the exact character. True char-precise scrolling needs a single
addressable body text widget; /body is currently segmented into
text/code/table/list parts. Deferred.
This commit is contained in:
Levi Neely 2026-10-02 11:04:05 +02:00
parent cc2b341754
commit 66a1ca11b5
4 changed files with 179 additions and 125 deletions

View File

@ -1035,8 +1035,18 @@ impl OrkState {
/// Strips planning line and properties drawer.
pub fn section_body(&self, doc_name: &str, id: &str) -> Option<String> {
self.get_doc(doc_name).and_then(|d| {
self.find_section(&d.doc, id).map(|s| {
// Body is section content minus children
self.find_section(&d.doc, id)
.map(|s| section_body_text(&d.content, s))
})
}
}
/// Transform a section's raw content region into its rendered `/body` text:
/// drop the leading planning line (SCHEDULED/DEADLINE/CLOSED) and property
/// drawer, then trim surrounding blank lines. This is the exact string the
/// GUI renders and the string `resolve_target` reports fuzzy offsets into.
fn section_body_text(content: &str, s: &Section) -> String {
// Body is section content minus children.
let start = s.headline.span.end.offset;
let end = if let Some(first_child) = s.children.first() {
first_child.headline.span.start.offset
@ -1044,11 +1054,11 @@ impl OrkState {
s.span.end.offset
};
if start >= end || end > d.content.len() {
if start >= end || end > content.len() {
return String::new();
}
let raw_body = &d.content[start..end];
let raw_body = &content[start..end];
// Strip leading planning line (SCHEDULED/DEADLINE/CLOSED)
// and properties drawer (:PROPERTIES: ... :END:)
@ -1067,9 +1077,11 @@ impl OrkState {
// Skip planning lines at the start (before any content)
// Planning lines contain SCHEDULED:, DEADLINE:, or CLOSED:
if !seen_content && (trimmed.contains("SCHEDULED:") ||
trimmed.contains("DEADLINE:") ||
trimmed.contains("CLOSED:")) {
if !seen_content
&& (trimmed.contains("SCHEDULED:")
|| trimmed.contains("DEADLINE:")
|| trimmed.contains("CLOSED:"))
{
continue;
}
@ -1104,9 +1116,6 @@ impl OrkState {
}
result_lines.join("\n")
})
})
}
}
// ═══════════════════════════════════════════════════════════════════
@ -1873,7 +1882,7 @@ impl OrkState {
/// kind: dedicated | fuzzy | external | notfound
/// doc: <doc-name> # owning document; omitted for external/notfound
/// section: <section-id> # omitted for external/notfound/bare file:DOC
/// offset: <n> # byte offset within the section, 0 for dedicated
/// offset: <n> # byte offset into the section's /body text
/// ```
///
/// Dispatch by target syntax:
@ -1983,30 +1992,26 @@ impl OrkState {
// 3. a headline whose title matches
// 4. (compat) a section whose slug id matches
// 5. the target text appearing literally in a section's content
// Each branch returns the owning section plus a byte offset relative to
// the start of that section's raw content (headline end), so a caller
// can scroll within `/body`. Headline/slug matches carry offset 0.
// Branches 1, 2 and 5 search the rendered `/body` text and report a
// byte offset into it, so a caller can locate the match in the exact
// string the GUI shows. Headline/slug matches carry offset 0.
let want = normalize_search(target);
// 1. Dedicated target: `<<target>>` or radio `<<<target>>>`.
for section in d.doc.all_sections() {
let (_content_start, raw) = self.section_content(&d.content, section);
if let Some(off) = find_org_target(raw, &want) {
let body = section_body_text(&d.content, section);
if let Some(off) = find_org_target(&body, &want) {
return Ok(fuzzy_result(doc_name, &self.section_id(section), off));
}
}
// 2. Named element: `#+NAME: target`.
// 2. Named element: `#+NAME: target`. Scan the rendered `/body` text
// directly — the parser only attaches affiliated keywords to some
// element kinds, so a text scan is more reliable and works for
// tables, blocks, paragraphs, etc. alike.
for section in d.doc.all_sections() {
let (content_start, _raw) = self.section_content(&d.content, section);
if let Some(elem) = section.content.iter().find(|e| {
e.affiliated.iter().any(|a| {
a.key.eq_ignore_ascii_case("NAME")
&& matches!(&a.value,
org_ast::AffiliatedValue::Text(v) if normalize_search(v) == want)
})
}) {
let off = elem.span.start.offset.saturating_sub(content_start);
let body = section_body_text(&d.content, section);
if let Some(off) = find_name_line(&body, &want) {
return Ok(fuzzy_result(doc_name, &self.section_id(section), off));
}
}
@ -2023,10 +2028,10 @@ impl OrkState {
return Ok(fuzzy_result(doc_name, &self.section_id(section), 0));
}
// 5. Plain text: the target appears literally in a section's content.
// 5. Plain text: the target appears literally in a section's body.
for section in d.doc.all_sections() {
let (_content_start, raw) = self.section_content(&d.content, section);
if let Some(off) = find_plain_text(raw, target) {
let body = section_body_text(&d.content, section);
if let Some(off) = find_plain_text(&body, target) {
return Ok(fuzzy_result(doc_name, &self.section_id(section), off));
}
}
@ -2034,24 +2039,6 @@ impl OrkState {
Ok("kind: notfound\n".to_string())
}
/// Return a section's raw content region: the slice from the end of its
/// headline to the start of its first child (or the section end), together
/// with that region's starting byte offset in the document. The offset lets
/// callers translate an absolute document position into a section-relative
/// one.
fn section_content<'a>(&self, content: &'a str, section: &Section) -> (usize, &'a str) {
let start = section.headline.span.end.offset;
let end = section
.children
.first()
.map(|c| c.headline.span.start.offset)
.unwrap_or(section.span.end.offset);
if start >= end || end > content.len() {
return (start, "");
}
(start, &content[start..end])
}
/// Map a `file:` link's file component to a served document name, if any.
/// Matches on the file stem (e.g. `notes.org`, `./notes.org`, `notes`
/// all map to the document `notes`).
@ -2137,19 +2124,40 @@ fn find_org_target(content: &str, want: &str) -> Option<usize> {
}
/// Find the first literal, case-insensitive occurrence of `needle` in a
/// section's raw `content`. Returns the byte offset relative to the start of
/// `content`. Whitespace is not normalized — this is the lowest-priority
/// fuzzy fallback.
fn find_plain_text(content: &str, needle: &str) -> Option<usize> {
/// section's `/body` text. Returns the byte offset relative to the start of
/// `body`. Whitespace is not normalized — this is the lowest-priority fuzzy
/// fallback.
fn find_plain_text(body: &str, needle: &str) -> Option<usize> {
let needle = needle.trim();
if needle.is_empty() {
return None;
}
let hay = content.to_lowercase();
let hay = body.to_lowercase();
let want = needle.to_lowercase();
hay.find(&want)
}
/// Find the `#+NAME: <want>` affiliated-keyword line in a section's `/body`
/// text and return the byte offset of that line. `want` is the normalized
/// name. Falls back to None if the line is not present in the rendered body.
fn find_name_line(body: &str, want: &str) -> Option<usize> {
let mut offset = 0;
for line in body.split_inclusive('\n') {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("#+") {
if let Some((key, value)) = rest.split_once(':') {
if key.trim().eq_ignore_ascii_case("NAME")
&& normalize_search(value) == want
{
return Some(offset);
}
}
}
offset += line.len();
}
None
}
/// Format a resolution result that names only the owning document (used for a
/// bare `file:DOC` link with no in-document target).
fn doc_result(doc: &str) -> String {
@ -2545,14 +2553,30 @@ mod tests {
#[test]
fn test_resolve_fuzzy_dedicated_target() {
// `<<target>>` resolves to the owning section with a byte offset into
// the section's raw content.
// the section's /body text.
let (_d, state) = state_with(
"#+TITLE: W\n* Alpha\nIntro line.\nHere is a <<my-anchor>> target.\n");
let r = state.resolve_target("w", "my-anchor").unwrap();
assert!(r.contains("kind: fuzzy"), "{}", r);
assert!(r.contains("section: alpha"), "{}", r);
// Offset is relative to section content; it points past the headline.
assert!(!r.contains("offset: 0\n"), "offset should be non-zero: {}", r);
// Body is "Intro line.\nHere is a <<my-anchor>> target." so `<<` sits
// 12 + 10 = 22 bytes in.
assert!(r.contains("offset: 22"), "{}", r);
}
#[test]
fn test_resolve_fuzzy_offset_is_body_relative() {
// Leading blank lines are stripped from /body. The reported offset must
// index into /body, not the raw content, so those blanks must not be
// counted.
let (_d, state) = state_with(
"#+TITLE: W\n* Alpha\n\n\nword <<tgt>>\n");
let r = state.resolve_target("w", "tgt").unwrap();
let body = state.section_body("w", "alpha").unwrap();
assert_eq!(body, "word <<tgt>>", "body should be trimmed of leading blanks");
assert!(r.contains("kind: fuzzy"), "{}", r);
// `<<` is 5 bytes into "word <<tgt>>".
assert!(r.contains("offset: 5"), "offset must be /body-relative: {}", r);
}
#[test]
@ -2565,6 +2589,25 @@ mod tests {
assert!(r.contains("section: alpha"), "{}", r);
}
#[test]
fn test_find_name_line_offset() {
let body = "Intro line.\nHere is a <<my-anchor>> target.\n#+NAME: fig-one\n| a | b |";
assert_eq!(super::find_name_line(body, "fig-one"), Some(44));
}
#[test]
fn test_resolve_name_offset_matches_body() {
// The #+NAME offset must index the rendered /body (the exact string the
// GUI shows), pointing at the start of the #+NAME line.
let (_d, state) = state_with(
"#+TITLE: Off\n* Alpha\n\n\nIntro line.\nHere is a <<my-anchor>> target.\n#+NAME: fig-one\n| a | b |\n* Beta\nThe quick brown FOX jumps.\n");
let body = state.section_body("w", "alpha").unwrap();
let r = state.resolve_target("w", "fig-one").unwrap();
let name_at = body.find("#+NAME").unwrap();
assert!(r.contains(&format!("offset: {}", name_at)),
"body={:?} name_at={} r={}", body, name_at, r);
}
#[test]
fn test_resolve_fuzzy_named_element() {
// `#+NAME: foo` makes `[[foo]]` resolve to the named element's section.

View File

@ -81,14 +81,18 @@ ListView {
property int flashRow: -1
// When the model reveals a section, select it, scroll it into view, and
// flash it briefly.
// 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.
Connections {
target: docModel
function onSectionRevealed(row) {
function onSectionRevealed(row, bodyOffset) {
if (row < 0)
return
outline.currentIndex = row
outline.positionViewAtIndex(row, ListView.Contain)
outline.positionViewAtIndex(
row, bodyOffset > 0 ? ListView.Beginning : ListView.Contain)
outline.flashRow = row
flashTimer.restart()
}

View File

@ -278,7 +278,7 @@ bool DocumentModel::cycleCheckbox(int row, int checkboxOrdinal, bool forward)
});
}
int DocumentModel::revealSection(const QString &sectionId)
int DocumentModel::revealSection(const QString &sectionId, int bodyOffset)
{
if (sectionId.isEmpty() || !m_sections.contains(sectionId))
return -1;
@ -299,7 +299,7 @@ int DocumentModel::revealSection(const QString &sectionId)
const int row = m_visibleRows.indexOf(sectionId);
if (row >= 0)
emit sectionRevealed(row);
emit sectionRevealed(row, bodyOffset);
return row;
}
@ -320,6 +320,7 @@ QString DocumentModel::followLink(const QString &target)
QString kind;
QString section;
QString targetDoc;
int offset = 0;
for (const QString &line : resp.split('\n', Qt::SkipEmptyParts)) {
const int colon = line.indexOf(':');
if (colon < 0)
@ -332,6 +333,8 @@ QString DocumentModel::followLink(const QString &target)
section = val;
else if (key == QLatin1String("doc"))
targetDoc = val;
else if (key == QLatin1String("offset"))
offset = val.toInt();
}
if (kind == QLatin1String("external")) {
@ -344,7 +347,7 @@ QString DocumentModel::followLink(const QString &target)
setDocument(targetDoc);
if (!section.isEmpty())
revealSection(section);
revealSection(section, offset);
}
return kind.isEmpty() ? QStringLiteral("notfound") : kind;
}

View File

@ -85,8 +85,10 @@ public:
// Reveal a section by id: expand all its ancestors so it becomes visible,
// and return its visible row index (-1 if the id is unknown). Emits
// sectionRevealed(row) so the view can scroll to and flash it.
Q_INVOKABLE int revealSection(const QString &sectionId);
// sectionRevealed(row, bodyOffset) so the view can scroll to and highlight
// it; bodyOffset is a byte offset into the section's rendered body (0 when
// the target is the section itself).
Q_INVOKABLE int revealSection(const QString &sectionId, int bodyOffset = 0);
// Resolve a link target via /<doc>/resolve, then act on it: external
// targets open in the OS handler; internal targets are revealed in the
@ -100,8 +102,10 @@ signals:
void documentChanged();
// Emitted when a section has been revealed (ancestors expanded); `row` is
// its visible index. The view scrolls to and highlights it.
void sectionRevealed(int row);
// its visible index and `bodyOffset` is a byte offset into the section's
// rendered body (0 when the target is the section itself). The view
// scrolls to and highlights it.
void sectionRevealed(int row, int bodyOffset);
private:
struct Section {