From 1033820229651c51ce024ef661518ffe972ff85f Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Fri, 2 Oct 2026 16:00:54 +0200 Subject: [PATCH] Add drag-and-drop structural moves with a live drop indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 [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. --- crates/ork-server/src/state.rs | 111 ++++++++++++++++----- ui/qml/OutlineView.qml | 141 ++++++++++++++++++++++++++ ui/src/documentmodel.cpp | 174 +++++++++++++++++++++++++++++++++ ui/src/documentmodel.h | 31 ++++++ 4 files changed, 431 insertions(+), 26 deletions(-) diff --git a/crates/ork-server/src/state.rs b/crates/ork-server/src/state.rs index 0ae0d2e..b02535d 100644 --- a/crates/ork-server/src/state.rs +++ b/crates/ork-server/src/state.rs @@ -355,7 +355,7 @@ impl OrkState { ("move", "move up|down — reorder among siblings", OrkState::ctl_move), ("promote", "promote — decrease subtree level", OrkState::ctl_promote), ("demote", "demote — increase subtree level", OrkState::ctl_demote), - ("refile", "refile [parent-id] — move subtree", OrkState::ctl_refile), + ("refile", "refile [parent-id] [before-id] — move subtree", OrkState::ctl_refile), ("archive", "archive — move subtree to .org_archive", OrkState::ctl_archive), ("lint", "lint [doc] — validate documents", OrkState::ctl_lint), ("execute", "execute [scope] [block] — run src blocks", OrkState::ctl_execute), @@ -429,11 +429,17 @@ impl OrkState { } fn ctl_refile(&self, args: &[&str]) -> Result { - if args.len() < 3 || args.len() > 4 { - return Err(bad_args("refile [parent-id]")); + if args.len() < 3 || args.len() > 5 { + return Err(bad_args("refile [parent-id] [before-id]")); } - let parent = args.get(3).copied(); - self.refile_section(args[0], args[1], args[2], parent)?; + // A literal "-" in the parent slot means "top level" (no parent); this + // 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()) } @@ -1826,12 +1832,17 @@ impl OrkState { /// 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 /// 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( &self, doc_name: &str, id: &str, to_doc: &str, to_parent: Option<&str>, + before: Option<&str>, ) -> Result<()> { let src = self.get_doc(doc_name) .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "source document not found"))?; @@ -1859,37 +1870,42 @@ impl OrkState { 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 { + 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 { - // Remove the subtree, then recompute the insertion point on the - // post-removal content by reparsing. let mut content = String::with_capacity(src.content.len()); content.push_str(&src.content[..start]); content.push_str(&src.content[end..]); self.update_doc(doc_name, &src.path, content)?; let doc_state = self.get_doc(to_doc).unwrap(); - let insert_pos = match to_parent { - Some(pid) => { - 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); + let pos = insert_pos(&doc_state.content, &doc_state.doc)?; + let new = insert_at(&doc_state.content, pos, &subtree); return self.update_doc(to_doc, &doc_state.path, new); } // Cross-document refile: insert into target, then remove from source. - let insert_pos = match to_parent { - Some(pid) => { - 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); + let pos = insert_pos(&dst.content, &dst.doc)?; + let dst_new = insert_at(&dst.content, pos, &subtree); self.update_doc(to_doc, &dst.path, dst_new)?; 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(); 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 b = state.doc_raw("b").unwrap(); @@ -2958,6 +2974,49 @@ mod tests { 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] fn test_archive_section() { use std::fs; diff --git a/ui/qml/OutlineView.qml b/ui/qml/OutlineView.qml index 9df527b..1e4e7f3 100644 --- a/ui/qml/OutlineView.qml +++ b/ui/qml/OutlineView.qml @@ -18,6 +18,54 @@ ListView { // here so the shared date picker can insert into it at the cursor. 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. function keywordColor(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 { id: del width: outline.width @@ -409,6 +517,39 @@ ListView { Layout.fillWidth: true 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 Label { visible: (del.model.hasChildren ?? false) diff --git a/ui/src/documentmodel.cpp b/ui/src/documentmodel.cpp index ca4b27e..e91152f 100644 --- a/ui/src/documentmodel.cpp +++ b/ui/src/documentmodel.cpp @@ -504,6 +504,180 @@ bool DocumentModel::setBody(int row, const QString &body) 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 [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 §ionId, int bodyOffset) { if (sectionId.isEmpty() || !m_sections.contains(sectionId)) diff --git a/ui/src/documentmodel.h b/ui/src/documentmodel.h index 0866342..6273e40 100644 --- a/ui/src/documentmodel.h +++ b/ui/src/documentmodel.h @@ -130,6 +130,37 @@ public: // for seeding an inline editor. Empty if the row or section is unknown. 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, // and return its visible row index (-1 if the id is unknown). Emits // sectionRevealed(row, bodyOffset) so the view can scroll to and highlight