diff --git a/crates/ork-server/src/state.rs b/crates/ork-server/src/state.rs index 34cbb58..a4b193a 100644 --- a/crates/ork-server/src/state.rs +++ b/crates/ork-server/src/state.rs @@ -1035,80 +1035,89 @@ impl OrkState { /// Strips planning line and properties drawer. pub fn section_body(&self, doc_name: &str, id: &str) -> Option { 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: # owning document; omitted for external/notfound /// section: # omitted for external/notfound/bare file:DOC - /// offset: # byte offset within the section, 0 for dedicated + /// offset: # 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: `<>` or radio `<<>>`. 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 { } /// 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 { +/// 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 { 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: ` 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 { + 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() { // `<>` 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 <> 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 <> 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 <>\n"); + let r = state.resolve_target("w", "tgt").unwrap(); + let body = state.section_body("w", "alpha").unwrap(); + assert_eq!(body, "word <>", "body should be trimmed of leading blanks"); + assert!(r.contains("kind: fuzzy"), "{}", r); + // `<<` is 5 bytes into "word <>". + 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 <> 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 <> 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. diff --git a/ui/qml/OutlineView.qml b/ui/qml/OutlineView.qml index 6062780..d0e5527 100644 --- a/ui/qml/OutlineView.qml +++ b/ui/qml/OutlineView.qml @@ -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() } diff --git a/ui/src/documentmodel.cpp b/ui/src/documentmodel.cpp index 3c2bf0b..af3ec2f 100644 --- a/ui/src/documentmodel.cpp +++ b/ui/src/documentmodel.cpp @@ -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; } diff --git a/ui/src/documentmodel.h b/ui/src/documentmodel.h index 1550e1c..21e0b80 100644 --- a/ui/src/documentmodel.h +++ b/ui/src/documentmodel.h @@ -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 //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 {