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:
parent
cc2b341754
commit
66a1ca11b5
|
|
@ -1035,80 +1035,89 @@ 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
|
||||
let start = s.headline.span.end.offset;
|
||||
let end = if let Some(first_child) = s.children.first() {
|
||||
first_child.headline.span.start.offset
|
||||
} else {
|
||||
s.span.end.offset
|
||||
};
|
||||
|
||||
if start >= end || end > d.content.len() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let raw_body = &d.content[start..end];
|
||||
|
||||
// Strip leading planning line (SCHEDULED/DEADLINE/CLOSED)
|
||||
// and properties drawer (:PROPERTIES: ... :END:)
|
||||
let lines: Vec<&str> = raw_body.lines().collect();
|
||||
let mut result_lines = Vec::new();
|
||||
let mut in_drawer = false;
|
||||
let mut seen_content = false; // Have we seen actual content yet?
|
||||
|
||||
for line in lines {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Skip empty lines before content
|
||||
if !seen_content && trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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:")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle drawer start
|
||||
if trimmed.starts_with(':') && trimmed.ends_with(':') && trimmed.len() > 2 {
|
||||
let drawer_name = &trimmed[1..trimmed.len()-1];
|
||||
if drawer_name.chars().all(|c| c.is_ascii_uppercase() || c == '_') {
|
||||
in_drawer = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drawer end
|
||||
if in_drawer {
|
||||
if trimmed == ":END:" {
|
||||
in_drawer = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// This is actual content
|
||||
seen_content = true;
|
||||
result_lines.push(line);
|
||||
}
|
||||
|
||||
// Trim leading/trailing empty lines
|
||||
while result_lines.first().map(|l| l.trim().is_empty()).unwrap_or(false) {
|
||||
result_lines.remove(0);
|
||||
}
|
||||
while result_lines.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
|
||||
result_lines.pop();
|
||||
}
|
||||
|
||||
result_lines.join("\n")
|
||||
})
|
||||
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
|
||||
} else {
|
||||
s.span.end.offset
|
||||
};
|
||||
|
||||
if start >= end || end > content.len() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let raw_body = &content[start..end];
|
||||
|
||||
// Strip leading planning line (SCHEDULED/DEADLINE/CLOSED)
|
||||
// and properties drawer (:PROPERTIES: ... :END:)
|
||||
let lines: Vec<&str> = raw_body.lines().collect();
|
||||
let mut result_lines = Vec::new();
|
||||
let mut in_drawer = false;
|
||||
let mut seen_content = false; // Have we seen actual content yet?
|
||||
|
||||
for line in lines {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Skip empty lines before content
|
||||
if !seen_content && trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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:"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle drawer start
|
||||
if trimmed.starts_with(':') && trimmed.ends_with(':') && trimmed.len() > 2 {
|
||||
let drawer_name = &trimmed[1..trimmed.len() - 1];
|
||||
if drawer_name.chars().all(|c| c.is_ascii_uppercase() || c == '_') {
|
||||
in_drawer = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drawer end
|
||||
if in_drawer {
|
||||
if trimmed == ":END:" {
|
||||
in_drawer = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// This is actual content
|
||||
seen_content = true;
|
||||
result_lines.push(line);
|
||||
}
|
||||
|
||||
// Trim leading/trailing empty lines
|
||||
while result_lines.first().map(|l| l.trim().is_empty()).unwrap_or(false) {
|
||||
result_lines.remove(0);
|
||||
}
|
||||
while result_lines.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
|
||||
result_lines.pop();
|
||||
}
|
||||
|
||||
result_lines.join("\n")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Section Writes
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -278,7 +278,7 @@ bool DocumentModel::cycleCheckbox(int row, int checkboxOrdinal, bool forward)
|
|||
});
|
||||
}
|
||||
|
||||
int DocumentModel::revealSection(const QString §ionId)
|
||||
int DocumentModel::revealSection(const QString §ionId, int bodyOffset)
|
||||
{
|
||||
if (sectionId.isEmpty() || !m_sections.contains(sectionId))
|
||||
return -1;
|
||||
|
|
@ -299,7 +299,7 @@ int DocumentModel::revealSection(const QString §ionId)
|
|||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 §ionId);
|
||||
// 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 §ionId, 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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue