Add drag-and-drop structural moves with a live drop indicator

Drag a section by its ⠿ grip to restructure the outline. Vertical pointer
position chooses the insertion gap; horizontal position (indent) chooses
the depth. A live indicator shows the resolved target — an insertion bar
indented to the resolved level plus a ghost label ("L3 under \"B\"") —
computed by the same function that commits, so the preview is truthful.

Resolving the ambiguity (is this a new L2, an L3 sibling, or an L4
child?): at a gap between row A (level La, above) and row B (level Lb,
below), the legal depth range is [max(1, Lb), La + 1]. The pointer's
indent is clamped into that range; the parent is then computed by walking
A's ancestor chain to the row at level-1 (never guessed), and the
before-sibling is the first row after the gap at that level under that
parent. The dragged subtree is excluded, so it can't drop into itself.

Server (refile insert-before):
- refile_section gains a `before` parameter: insert the subtree
  immediately before that sibling instead of appending at the end of the
  parent. The ctl verb becomes
  `refile <doc> <id> <to-doc> [parent-id] [before-id]`, and a literal "-"
  in the parent slot means top level (so a before-id can be given while
  refiling to the top).

Model:
- moveSection/promoteSection/demoteSection/refileSection drive the move,
  promote, demote, and refile ctl verbs and reload on success.
- resolveDrop(draggedRow, gapAboveRow, desiredLevel) implements the
  clamp + ancestor-walk and returns {valid, level, parentId, parentTitle,
  beforeId}. sectionIdAt(row) exposes the row's id.

GUI:
- A drag grip per headline with a manual-drag MouseArea mapping the
  pointer to ListView content coords and calling resolveDrop live; the
  drop indicator overlay renders from the result; release commits via
  refileSection.

Verified: server tests (75 passed, +3: refile before-sibling same-doc,
before top-level, and the ctl "-" top-level placeholder). The drop
resolution was checked with a standalone harness over the ambiguous
C|E-gap cases (indent 1/2/3/4 → top-before-E / L2-under-A / L3-under-B /
L4-under-C, clamping, and self-drop exclusion). Live 9P confirmed
`refile d f d b c` (reparent + relevel + insert-before) and
`refile d f d - a` (promote to top, insert-before). GUI builds clean.
This commit is contained in:
Levi Neely 2026-10-02 16:00:54 +02:00
parent 9ed3b730e6
commit 1033820229
4 changed files with 431 additions and 26 deletions

View File

@ -355,7 +355,7 @@ impl OrkState {
("move", "move <doc> <id> up|down — reorder among siblings", OrkState::ctl_move), ("move", "move <doc> <id> up|down — reorder among siblings", OrkState::ctl_move),
("promote", "promote <doc> <id> — decrease subtree level", OrkState::ctl_promote), ("promote", "promote <doc> <id> — decrease subtree level", OrkState::ctl_promote),
("demote", "demote <doc> <id> — increase subtree level", OrkState::ctl_demote), ("demote", "demote <doc> <id> — increase subtree level", OrkState::ctl_demote),
("refile", "refile <doc> <id> <to-doc> [parent-id] — move subtree", OrkState::ctl_refile), ("refile", "refile <doc> <id> <to-doc> [parent-id] [before-id] — move subtree", OrkState::ctl_refile),
("archive", "archive <doc> <id> — move subtree to <doc>.org_archive", OrkState::ctl_archive), ("archive", "archive <doc> <id> — move subtree to <doc>.org_archive", OrkState::ctl_archive),
("lint", "lint [doc] — validate documents", OrkState::ctl_lint), ("lint", "lint [doc] — validate documents", OrkState::ctl_lint),
("execute", "execute <doc> [scope] [block] — run src blocks", OrkState::ctl_execute), ("execute", "execute <doc> [scope] [block] — run src blocks", OrkState::ctl_execute),
@ -429,11 +429,17 @@ impl OrkState {
} }
fn ctl_refile(&self, args: &[&str]) -> Result<String> { fn ctl_refile(&self, args: &[&str]) -> Result<String> {
if args.len() < 3 || args.len() > 4 { if args.len() < 3 || args.len() > 5 {
return Err(bad_args("refile <doc> <id> <to-doc> [parent-id]")); return Err(bad_args("refile <doc> <id> <to-doc> [parent-id] [before-id]"));
} }
let parent = args.get(3).copied(); // A literal "-" in the parent slot means "top level" (no parent); this
self.refile_section(args[0], args[1], args[2], parent)?; // lets callers specify a before-id while refiling to top level.
let parent = match args.get(3).copied() {
Some("-") | Some("") => None,
other => other,
};
let before = args.get(4).copied();
self.refile_section(args[0], args[1], args[2], parent, before)?;
Ok("ok\n".to_string()) Ok("ok\n".to_string())
} }
@ -1826,12 +1832,17 @@ impl OrkState {
/// Move a section's subtree to another document (or elsewhere in the same /// Move a section's subtree to another document (or elsewhere in the same
/// document), re-leveling it to the target depth. `to_parent` places it as /// document), re-leveling it to the target depth. `to_parent` places it as
/// a child of that section; otherwise it becomes a top-level section. /// a child of that section; otherwise it becomes a top-level section.
/// `before` (if given) inserts the subtree immediately before that sibling
/// (which must exist at the insertion point after re-leveling); otherwise
/// the subtree is appended at the end of the parent's children (or the end
/// of the document for a top-level refile).
pub fn refile_section( pub fn refile_section(
&self, &self,
doc_name: &str, doc_name: &str,
id: &str, id: &str,
to_doc: &str, to_doc: &str,
to_parent: Option<&str>, to_parent: Option<&str>,
before: Option<&str>,
) -> Result<()> { ) -> Result<()> {
let src = self.get_doc(doc_name) let src = self.get_doc(doc_name)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "source document not found"))?; .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "source document not found"))?;
@ -1859,37 +1870,42 @@ impl OrkState {
subtree.push('\n'); subtree.push('\n');
} }
// Same-document refile: compute both edits against one content string. // Resolve the insertion byte offset within `content`, given the parsed
// `doc`. `before` takes precedence (insert at that sibling's headline
// start); otherwise insert at the end of the parent, or end of content.
let insert_pos = |content: &str, doc: &Document| -> Result<usize> {
if let Some(bid) = before {
let b = self.find_section(doc, bid)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "before section not found"))?;
return Ok(b.headline.span.start.offset);
}
match to_parent {
Some(pid) => {
let parent = self.find_section(doc, pid)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "parent section not found"))?;
Ok(self.find_section_end(content, parent))
}
None => Ok(content.len()),
}
};
// Same-document refile: remove the subtree first, then recompute the
// insertion point on the post-removal (reparsed) content.
if doc_name == to_doc { if doc_name == to_doc {
// Remove the subtree, then recompute the insertion point on the
// post-removal content by reparsing.
let mut content = String::with_capacity(src.content.len()); let mut content = String::with_capacity(src.content.len());
content.push_str(&src.content[..start]); content.push_str(&src.content[..start]);
content.push_str(&src.content[end..]); content.push_str(&src.content[end..]);
self.update_doc(doc_name, &src.path, content)?; self.update_doc(doc_name, &src.path, content)?;
let doc_state = self.get_doc(to_doc).unwrap(); let doc_state = self.get_doc(to_doc).unwrap();
let insert_pos = match to_parent { let pos = insert_pos(&doc_state.content, &doc_state.doc)?;
Some(pid) => { let new = insert_at(&doc_state.content, pos, &subtree);
let parent = self.find_section(&doc_state.doc, pid)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "parent section not found"))?;
self.find_section_end(&doc_state.content, parent)
}
None => doc_state.content.len(),
};
let new = insert_at(&doc_state.content, insert_pos, &subtree);
return self.update_doc(to_doc, &doc_state.path, new); return self.update_doc(to_doc, &doc_state.path, new);
} }
// Cross-document refile: insert into target, then remove from source. // Cross-document refile: insert into target, then remove from source.
let insert_pos = match to_parent { let pos = insert_pos(&dst.content, &dst.doc)?;
Some(pid) => { let dst_new = insert_at(&dst.content, pos, &subtree);
let parent = self.find_section(&dst.doc, pid).unwrap();
self.find_section_end(&dst.content, parent)
}
None => dst.content.len(),
};
let dst_new = insert_at(&dst.content, insert_pos, &subtree);
self.update_doc(to_doc, &dst.path, dst_new)?; self.update_doc(to_doc, &dst.path, dst_new)?;
let mut src_new = String::with_capacity(src.content.len()); let mut src_new = String::with_capacity(src.content.len());
@ -2949,7 +2965,7 @@ mod tests {
fs::write(dir.path().join("b.org"), "#+TITLE: B\n* Target\n").unwrap(); fs::write(dir.path().join("b.org"), "#+TITLE: B\n* Target\n").unwrap();
let state = OrkState::new(dir.path()).unwrap(); let state = OrkState::new(dir.path()).unwrap();
state.refile_section("a", "move-me", "b", Some("target")).unwrap(); state.refile_section("a", "move-me", "b", Some("target"), None).unwrap();
let a = state.doc_raw("a").unwrap(); let a = state.doc_raw("a").unwrap();
let b = state.doc_raw("b").unwrap(); let b = state.doc_raw("b").unwrap();
@ -2958,6 +2974,49 @@ mod tests {
assert!(b.contains("body"), "body carried over"); assert!(b.contains("body"), "body carried over");
} }
#[test]
fn test_refile_before_sibling_same_doc() {
// Refile a top-level section to become a child of Parent, inserted
// before the existing child "Beta" (not appended after it).
let (_d, state) = state_with(
"#+TITLE: W\n* Parent\n** Alpha\n** Beta\n* Mover\nbody\n",
);
state.refile_section("w", "mover", "w", Some("parent"), Some("beta")).unwrap();
let raw = state.doc_raw("w").unwrap();
// Mover becomes a level-2 child of Parent, positioned before Beta.
let a = raw.find("** Alpha").unwrap();
let m = raw.find("** Mover").unwrap();
let b = raw.find("** Beta").unwrap();
assert!(a < m && m < b, "order should be Alpha, Mover, Beta; got {:?}", raw);
assert!(raw.contains("body"), "body carried over");
assert!(!raw.starts_with("* Mover"), "no longer top-level");
}
#[test]
fn test_refile_before_top_level() {
// Refile under no parent (top level), before an existing top section.
let (_d, state) = state_with("#+TITLE: W\n* One\n* Two\n** Child\n");
state.refile_section("w", "child", "w", None, Some("one")).unwrap();
let raw = state.doc_raw("w").unwrap();
let c = raw.find("* Child").unwrap();
let one = raw.find("* One").unwrap();
assert!(c < one, "Child promoted to top level before One; got {:?}", raw);
// Promoted from level 2 to level 1.
assert!(raw.contains("\n* Child") || raw.starts_with("* Child"));
}
#[test]
fn test_ctl_refile_dash_parent_means_top_level() {
// Via the ctl verb, "-" in the parent slot means top level, so a
// before-id can be given while refiling to the top.
let (_d, state) = state_with("#+TITLE: W\n* One\n* Two\n** Child\n");
let reply = state.handle_ctl("refile w child w - one").unwrap();
assert_eq!(reply.trim(), "ok");
let raw = state.doc_raw("w").unwrap();
assert!(raw.find("* Child").unwrap() < raw.find("* One").unwrap(),
"Child at top before One; got {:?}", raw);
}
#[test] #[test]
fn test_archive_section() { fn test_archive_section() {
use std::fs; use std::fs;

View File

@ -18,6 +18,54 @@ ListView {
// here so the shared date picker can insert into it at the cursor. // here so the shared date picker can insert into it at the cursor.
property var insertTarget: null property var insertTarget: null
// ── Drag-and-drop structural move state ──────────────────────────────
// The visible row currently being dragged (-1 = none), the live resolved
// drop target from docModel.resolveDrop, and the gap geometry for drawing
// the insertion indicator.
property int dragRow: -1
property var dropInfo: null // {valid, level, parentId, parentTitle, beforeId}
property real dropIndicatorY: 0 // content-y of the insertion bar
property int dropGapAbove: -2 // visible row above the gap (-1 = top)
// Recompute the drop target from a pointer position in content coords.
function updateDrop(contentX, contentY) {
if (dragRow < 0) return
// Which row is the pointer over? The gap is above or below its middle.
var r = indexAt(1, contentY)
if (r < 0) {
// Past the last row → gap at the very bottom.
r = count - 1
}
var item = itemAtIndex(r)
var gapAbove
var gapY
if (item) {
var mid = item.y + item.height / 2
if (contentY < mid) { gapAbove = r - 1; gapY = item.y }
else { gapAbove = r; gapY = item.y + item.height }
} else {
gapAbove = r; gapY = contentY
}
// Desired level from horizontal indent. Row left padding is
// indent(level) + 8; one level ≈ indentWidth px.
var desired = Math.round((contentX - 8) / outline.indentWidth) + 1
if (desired < 1) desired = 1
var info = docModel.resolveDrop(dragRow, gapAbove, desired)
outline.dropInfo = info
outline.dropGapAbove = gapAbove
outline.dropIndicatorY = gapY
}
// Commit the current drop (called on drag release).
function commitDrop() {
if (dragRow >= 0 && dropInfo && dropInfo.valid) {
docModel.refileSection(dragRow, dropInfo.parentId, dropInfo.beforeId)
}
dragRow = -1
dropInfo = null
dropGapAbove = -2
}
// Palette for TODO keywords by state. // Palette for TODO keywords by state.
function keywordColor(kw) { function keywordColor(kw) {
switch (kw) { switch (kw) {
@ -136,6 +184,66 @@ ListView {
} }
} }
// Drop indicator shown while dragging a section. A horizontal insertion
// bar at the resolved gap, indented to the resolved level, with a ghost
// label naming the resolved level and parent. Driven entirely by
// outline.dropInfo so the preview matches exactly what commitDrop() does.
Item {
id: dropIndicator
z: 100
visible: outline.dragRow >= 0 && outline.dropInfo && outline.dropInfo.valid
// Convert the content-y gap position to viewport coordinates.
y: outline.dropIndicatorY - outline.contentY
x: 0
width: outline.width
height: 2
// Indent of the insertion bar = left padding for the resolved level.
readonly property int barIndent: visible
? 8 + (outline.dropInfo.level - 1) * outline.indentWidth : 0
Rectangle {
x: dropIndicator.barIndent
width: parent.width - x - 8
height: 2
y: -1
color: theme.linkColor
radius: 1
}
// Left cap circle at the insertion point.
Rectangle {
x: dropIndicator.barIndent - 3
y: -3
width: 6; height: 6; radius: 3
color: theme.linkColor
}
// Ghost label: "L{level} under \"{parent}\"" (or "top level").
Rectangle {
x: dropIndicator.barIndent
y: 2
width: ghostLabel.implicitWidth + 8
height: ghostLabel.implicitHeight + 2
radius: 3
color: theme.windowColor
border.color: theme.linkColor
border.width: 1
Label {
id: ghostLabel
anchors.centerIn: parent
text: {
if (!outline.dropInfo || !outline.dropInfo.valid) return ""
var lvl = "L" + outline.dropInfo.level
var p = (outline.dropInfo.parentTitle && outline.dropInfo.parentTitle.length > 0)
? qsTr("under \"%1\"").arg(outline.dropInfo.parentTitle)
: qsTr("at top level")
return lvl + " " + p
}
color: theme.textColor
font.pointSize: outline.baseFontSize - 3
}
}
}
delegate: Rectangle { delegate: Rectangle {
id: del id: del
width: outline.width width: outline.width
@ -409,6 +517,39 @@ ListView {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 8 spacing: 8
// Drag grip: drag a section (and its subtree) to restructure.
// A live indicator shows the resolved drop target; releasing
// commits a refile. Horizontal position chooses the depth,
// vertical position chooses the gap.
Label {
text: "⠿"
color: theme.placeholderTextColor
font.pointSize: outline.baseFontSize - 1
opacity: gripMouse.containsMouse || outline.dragRow === del.index ? 1.0 : 0.3
MouseArea {
id: gripMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.SizeAllCursor
preventStealing: true
onPressed: {
outline.dragRow = del.index
}
onPositionChanged: (mouse) => {
if (outline.dragRow < 0) return
// Map the pointer to ListView content coordinates.
var p = mapToItem(outline.contentItem, mouse.x, mouse.y)
outline.updateDrop(p.x, p.y)
}
onReleased: outline.commitDrop()
onCanceled: {
outline.dragRow = -1
outline.dropInfo = null
}
}
}
// Expand/collapse indicator for sections with children // Expand/collapse indicator for sections with children
Label { Label {
visible: (del.model.hasChildren ?? false) visible: (del.model.hasChildren ?? false)

View File

@ -504,6 +504,180 @@ bool DocumentModel::setBody(int row, const QString &body)
return true; return true;
} }
// Issue a /ctl command and return true if it succeeded (reply starts "ok").
// On success the model is reloaded, since structural edits change the tree.
bool DocumentModel::moveSection(int row, bool up)
{
if (!m_client || m_document.isEmpty() || row < 0 || row >= m_visibleRows.size())
return false;
const QString id = m_visibleRows[row];
QString doc = m_document;
if (doc.startsWith('/'))
doc = doc.mid(1);
const QString cmd = QStringLiteral("move %1 %2 %3")
.arg(doc, id, up ? QStringLiteral("up") : QStringLiteral("down"));
const QString reply = m_client->rdwr(QStringLiteral("/ctl"), cmd);
if (!reply.startsWith(QLatin1String("ok")))
return false;
reload();
return true;
}
bool DocumentModel::promoteSection(int row)
{
if (!m_client || m_document.isEmpty() || row < 0 || row >= m_visibleRows.size())
return false;
const QString id = m_visibleRows[row];
QString doc = m_document;
if (doc.startsWith('/'))
doc = doc.mid(1);
const QString reply = m_client->rdwr(QStringLiteral("/ctl"),
QStringLiteral("promote %1 %2").arg(doc, id));
if (!reply.startsWith(QLatin1String("ok")))
return false;
reload();
return true;
}
bool DocumentModel::demoteSection(int row)
{
if (!m_client || m_document.isEmpty() || row < 0 || row >= m_visibleRows.size())
return false;
const QString id = m_visibleRows[row];
QString doc = m_document;
if (doc.startsWith('/'))
doc = doc.mid(1);
const QString reply = m_client->rdwr(QStringLiteral("/ctl"),
QStringLiteral("demote %1 %2").arg(doc, id));
if (!reply.startsWith(QLatin1String("ok")))
return false;
reload();
return true;
}
bool DocumentModel::refileSection(int row, const QString &parentId, const QString &beforeId)
{
if (!m_client || m_document.isEmpty() || row < 0 || row >= m_visibleRows.size())
return false;
const QString id = m_visibleRows[row];
QString doc = m_document;
if (doc.startsWith('/'))
doc = doc.mid(1);
// refile <doc> <id> <to-doc> [parent-id] [before-id]; same-doc refile uses
// doc as to-doc. A "-" placeholder keeps positional args when only
// before-id is given (parent empty = top level).
QString cmd = QStringLiteral("refile %1 %2 %1").arg(doc, id);
const QString parent = parentId.isEmpty() ? QStringLiteral("-") : parentId;
if (!beforeId.isEmpty())
cmd += QStringLiteral(" %1 %2").arg(parent, beforeId);
else if (!parentId.isEmpty())
cmd += QStringLiteral(" %1").arg(parentId);
const QString reply = m_client->rdwr(QStringLiteral("/ctl"), cmd);
if (!reply.startsWith(QLatin1String("ok")))
return false;
reload();
return true;
}
QString DocumentModel::sectionIdAt(int row)
{
if (row < 0 || row >= m_visibleRows.size())
return QString();
return m_visibleRows[row];
}
QVariantMap DocumentModel::resolveDrop(int draggedRow, int gapAboveRow, int desiredLevel)
{
QVariantMap result;
result[QStringLiteral("valid")] = false;
if (draggedRow < 0 || draggedRow >= m_visibleRows.size())
return result;
const QString draggedId = m_visibleRows[draggedRow];
if (!m_sections.contains(draggedId))
return result;
// The rows covered by the dragged subtree (contiguous in visible order,
// from draggedRow until a row at level <= dragged level).
const int dragLevel = m_sections[draggedId].level;
int dragEnd = draggedRow + 1; // exclusive
while (dragEnd < m_visibleRows.size()) {
const QString rid = m_visibleRows[dragEnd];
if (!m_sections.contains(rid)) break;
if (m_sections[rid].level <= dragLevel) break;
++dragEnd;
}
auto inDragged = [&](int r) { return r >= draggedRow && r < dragEnd; };
// Row A directly above the gap (skip the dragged subtree), and its level.
int aRow = gapAboveRow;
while (aRow >= 0 && inDragged(aRow))
--aRow;
const int aLevel = (aRow >= 0 && m_sections.contains(m_visibleRows[aRow]))
? m_sections[m_visibleRows[aRow]].level : 0;
// Row B directly below the gap (skip the dragged subtree), and its level.
int bRow = gapAboveRow + 1;
while (bRow < m_visibleRows.size() && inDragged(bRow))
++bRow;
const int bLevel = (bRow < m_visibleRows.size() && m_sections.contains(m_visibleRows[bRow]))
? m_sections[m_visibleRows[bRow]].level : 0;
// Legal depth range for this gap: no deeper than one below A, no shallower
// than B (else we'd land inside the wrong subtree). Clamp the desired.
const int hi = (aRow >= 0) ? aLevel + 1 : 1;
const int lo = (bLevel > 0) ? bLevel : 1;
int level = desiredLevel;
if (level < lo) level = lo;
if (level > hi) level = hi;
if (level < 1) level = 1;
// Parent = the nearest section at or above the gap whose level is level-1.
// Walk up A's ancestor chain (A itself if level == aLevel+1).
QString parentId;
QString parentTitle;
if (level > 1) {
int walk = aRow;
while (walk >= 0) {
const QString rid = m_visibleRows[walk];
if (m_sections.contains(rid) && m_sections[rid].level == level - 1) {
parentId = rid;
parentTitle = m_sections[rid].title;
break;
}
--walk;
}
// If no ancestor at level-1 exists, the drop is not legal.
if (parentId.isEmpty())
return result;
}
// Before-sibling = first row after the gap (skipping the dragged subtree)
// at exactly `level` under the resolved parent. If we first hit a row at a
// shallower level, we've left the parent → append (empty beforeId).
QString beforeId;
for (int r = bRow; r < m_visibleRows.size(); ++r) {
if (inDragged(r)) continue;
const QString rid = m_visibleRows[r];
if (!m_sections.contains(rid)) break;
const int rl = m_sections[rid].level;
if (rl < level) break; // left the parent's children
if (rl == level) { // candidate sibling
if (m_sections[rid].parentId == parentId)
beforeId = rid;
break;
}
// rl > level: still inside an earlier sibling's subtree; keep scanning.
}
result[QStringLiteral("valid")] = true;
result[QStringLiteral("level")] = level;
result[QStringLiteral("parentId")] = parentId;
result[QStringLiteral("parentTitle")] = parentTitle;
result[QStringLiteral("beforeId")] = beforeId;
return result;
}
int DocumentModel::revealSection(const QString &sectionId, int bodyOffset) int DocumentModel::revealSection(const QString &sectionId, int bodyOffset)
{ {
if (sectionId.isEmpty() || !m_sections.contains(sectionId)) if (sectionId.isEmpty() || !m_sections.contains(sectionId))

View File

@ -130,6 +130,37 @@ public:
// for seeding an inline editor. Empty if the row or section is unknown. // for seeding an inline editor. Empty if the row or section is unknown.
Q_INVOKABLE QString bodyText(int row); Q_INVOKABLE QString bodyText(int row);
// Structural edits via the server's /ctl verbs. Each operates on the
// section at `row` (and its whole subtree), reparses, and reloads the
// model. Returns true on success; on failure the server's message is left
// in the OrkClient error.
//
// moveSection: reorder among siblings (up = earlier, else later).
// promoteSection/demoteSection: shift the subtree's heading level.
Q_INVOKABLE bool moveSection(int row, bool up);
Q_INVOKABLE bool promoteSection(int row);
Q_INVOKABLE bool demoteSection(int row);
// Refile the subtree at `row` under `parentId` (empty = top level),
// inserted before `beforeId` (empty = append at the end of the parent's
// children / end of document). Uses the /ctl refile verb.
Q_INVOKABLE bool refileSection(int row, const QString &parentId,
const QString &beforeId);
// The section id at a visible row (empty if unknown).
Q_INVOKABLE QString sectionIdAt(int row);
// Resolve a drag-and-drop drop point to an authoritative target.
// `gapAboveRow` is the visible row directly above the insertion gap (-1 =
// the gap at the very top). `desiredLevel` is the heading level the user
// is aiming at via horizontal indent. The result is clamped to the legal
// range for that gap and returned as a map:
// { valid, level, parentId, parentTitle, beforeId }
// where parentId/beforeId are empty for top level / append. The dragged
// row is excluded so a subtree cannot be dropped into itself.
Q_INVOKABLE QVariantMap resolveDrop(int draggedRow, int gapAboveRow,
int desiredLevel);
// Reveal a section by id: expand all its ancestors so it becomes visible, // 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 // and return its visible row index (-1 if the id is unknown). Emits
// sectionRevealed(row, bodyOffset) so the view can scroll to and highlight // sectionRevealed(row, bodyOffset) so the view can scroll to and highlight