Support cross-file refile via spring-loaded drag

Dragging a section's grip can now drop it into another file: hover the
drag over a document in the sidebar for ~450ms and the view springs to
that file, after which the normal in-file drop chooses the exact
position. The subtree is removed from the source file and inserted in the
target (reparented, re-leveled, and placed before the chosen sibling).

The key change is making the drag scene-level so it survives the view
switching files mid-drag:

Model (decouple source from view):
- beginDrag(row) captures (sourceDoc, sourceId) by value; dragSourceDoc()/
  dragSourceId() expose them for the drag payload.
- resolveDrop(gapAboveRow, desiredLevel) resolves against the current view
  and excludes the dragged subtree only when it lives in the current file
  (so cross-file drags, whose source isn't present, exclude nothing).
- commitDrag(parentId, beforeId) issues refile <sourceDoc> <id> <curDoc>
  … so it targets whatever file is now shown; endDrag() clears state.
- Drops the old row-indexed refileSection/resolveDrop.

GUI:
- OutlineView: the grip starts a Qt Automatic (scene) drag on an invisible
  proxy carrying the section payload (keys ["ork-section"]); a DropArea
  over the outline resolves the live position and commits on drop. Because
  the drag is scene-level, springing the view no longer cancels it.
- Main.qml: each sidebar document is a DropArea with a 450ms spring Timer
  that selects that document when a section is held over it; it highlights
  while a drag hovers.

Verified: cross-file refile over 9P (refile src mover tgt two child) moves
the subtree between files, re-levels it, inserts before the sibling, and
carries its body. Server tests still green. GUI builds clean.

The scene-drag gesture and spring timing can't be verified headlessly;
the data path (begin/resolve/commit + cross-doc refile) is confirmed.
This commit is contained in:
Levi Neely 2026-10-05 09:35:34 +02:00
parent 614378ffa4
commit b329bcebd0
4 changed files with 233 additions and 126 deletions

View File

@ -165,6 +165,7 @@ ApplicationWindow {
clip: true
model: []
delegate: ItemDelegate {
id: docDelegate
required property var modelData
width: docList.width
highlighted: root.currentDoc === modelData.name
@ -174,8 +175,38 @@ ApplicationWindow {
topPadding: root.baseFontSize * 0.4
bottomPadding: root.baseFontSize * 0.4
background: Rectangle {
color: highlighted ? theme.highlightColor : "transparent"
opacity: highlighted ? 0.3 : 1.0
color: (docDrop.containsDrag) ? theme.highlightColor
: (docDelegate.highlighted ? theme.highlightColor : "transparent")
opacity: docDrop.containsDrag ? 0.5
: (docDelegate.highlighted ? 0.3 : 1.0)
// While a section is dragged over a non-current doc,
// show a border that fills as the spring timer runs.
border.color: theme.linkColor
border.width: docDrop.containsDrag
&& modelData.name !== root.currentDoc ? 1 : 0
}
// Spring-loaded cross-file drop target: hovering a
// dragged section over another document for a moment
// switches the view to it; the drop then lands in that
// file via the outline's normal in-file resolution.
DropArea {
id: docDrop
anchors.fill: parent
keys: ["ork-section"]
onEntered: {
if (docDelegate.modelData.name !== root.currentDoc)
springTimer.restart()
}
onExited: springTimer.stop()
Timer {
id: springTimer
interval: 450
onTriggered: {
if (docDrop.containsDrag)
root.selectDoc(docDelegate.modelData.name)
}
}
}
contentItem: ColumnLayout {
spacing: root.baseFontSize * 0.15

View File

@ -19,23 +19,23 @@ ListView {
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
// A drag is scene-level (Qt Drag + DropArea) so the view can spring-load
// to another file mid-drag without cancelling it. dragActive is true for
// the duration; dropInfo is the live resolved target over the current
// outline; the indicator geometry is in content coordinates.
property bool dragActive: false
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)
property bool dropOverOutline: false // pointer currently over the outline
// Recompute the drop target from a pointer position in content coords.
// The dragged section is tracked by the model (beginDrag), so resolveDrop
// needs only the gap and desired level.
function updateDrop(contentX, contentY) {
if (dragRow < 0) return
// Which row is the pointer over? The gap is above or below its middle.
if (!dragActive) return
var r = indexAt(1, contentY)
if (r < 0) {
// Past the last row → gap at the very bottom.
if (r < 0)
r = count - 1
}
var item = itemAtIndex(r)
var gapAbove
var gapY
@ -46,24 +46,23 @@ ListView {
} else {
gapAbove = r; gapY = contentY
}
// Desired level from horizontal indent. Row left padding is
// indent(level) + 8; one level ≈ indentWidth px.
// Desired level from horizontal indent (row left padding = indent + 8).
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.dropInfo = docModel.resolveDrop(gapAbove, desired)
outline.dropIndicatorY = gapY
}
// Commit the current drop (called on drag release).
// Commit the current drop into the current document (may be a different
// file than the drag started in — the model holds the source).
function commitDrop() {
if (dragRow >= 0 && dropInfo && dropInfo.valid) {
docModel.refileSection(dragRow, dropInfo.parentId, dropInfo.beforeId)
}
dragRow = -1
if (dragActive && dropOverOutline && dropInfo && dropInfo.valid)
docModel.commitDrag(dropInfo.parentId, dropInfo.beforeId)
else
docModel.endDrag()
dragActive = false
dropInfo = null
dropGapAbove = -2
dropOverOutline = false
}
// Palette for TODO keywords by state.
@ -184,6 +183,31 @@ ListView {
}
}
// Drop target covering the outline viewport. Receives the scene drag
// (started from a row's grip) and resolves the in-file position live.
// Because the drag is scene-level, this DropArea keeps working even after
// the view springs to a different file mid-drag.
DropArea {
id: outlineDrop
anchors.fill: parent
z: 90
keys: ["ork-section"]
onEntered: outline.dropOverOutline = true
onExited: outline.dropOverOutline = false
onPositionChanged: (drag) => {
outline.dropOverOutline = true
// DropArea-local coords are viewport coords; add contentY for the
// content coordinate the row geometry uses.
outline.updateDrop(drag.x, drag.y + outline.contentY)
}
onDropped: (drop) => {
outline.dropOverOutline = true
outline.commitDrop()
drop.accept()
}
}
// 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
@ -191,7 +215,8 @@ ListView {
Item {
id: dropIndicator
z: 100
visible: outline.dragRow >= 0 && outline.dropInfo && outline.dropInfo.valid
visible: outline.dragActive && outline.dropOverOutline
&& outline.dropInfo && outline.dropInfo.valid
// Convert the content-y gap position to viewport coordinates.
y: outline.dropIndicatorY - outline.contentY
x: 0
@ -517,21 +542,20 @@ ListView {
Layout.fillWidth: true
spacing: 8
// Drag grip (left gutter): a dedicated drag control. Pressing
// it disables ListView flicking for the duration of the press,
// so the grip — not the enclosing Flickable — owns the gesture;
// dragging then restructures the section (and its subtree) and
// releasing commits a refile. Horizontal position chooses the
// depth, vertical the gap. Flicking is restored on release.
// Drag grip (left gutter): starts a scene-level drag of this
// section (and its subtree). The drag is a Qt Automatic drag so
// it is delivered to DropAreas across the window and survives
// the view springing to another file mid-drag. The outline's
// DropArea resolves the in-file position; a sidebar doc's
// DropArea spring-loads to that file.
Rectangle {
id: gripLabel
Layout.preferredWidth: outline.baseFontSize + 8
Layout.preferredHeight: outline.baseFontSize + 8
radius: 3
readonly property bool active: outline.dragRow === del.index
|| gripPress.pressed
readonly property bool active: gripMouse.pressed
color: active ? theme.highlightColor
: (gripHover.hovered ? theme.alternateBaseColor : "transparent")
: (gripMouse.containsMouse ? theme.alternateBaseColor : "transparent")
Label {
anchors.centerIn: parent
@ -539,49 +563,51 @@ ListView {
color: gripLabel.active ? theme.highlightedTextColor
: theme.placeholderTextColor
font.pointSize: outline.baseFontSize - 1
opacity: gripHover.hovered || gripLabel.active ? 1.0 : 0.4
opacity: gripMouse.containsMouse || gripLabel.active ? 1.0 : 0.4
}
HoverHandler {
id: gripHover
cursorShape: gripLabel.active ? Qt.ClosedHandCursor : Qt.OpenHandCursor
// Invisible proxy that carries the drag payload. Starting a
// Qt Automatic drag on it hands the gesture to the window's
// drag manager, independent of this (destructible) delegate.
Item {
id: dragProxy
anchors.fill: parent
Drag.active: false
Drag.dragType: Drag.Automatic
Drag.supportedActions: Qt.MoveAction
Drag.keys: ["ork-section"]
}
// Own the gesture: while the grip is pressed, the ListView
// must not flick. This is what makes the gutter behave as a
// separate control rather than fighting the Flickable.
TapHandler {
id: gripPress
onPressedChanged: outline.interactive = !pressed
}
// The structural drag. The Flickable is non-interactive
// while pressed, so the handler takes the grab cleanly.
DragHandler {
id: gripDrag
target: null // track only; don't move the item
dragThreshold: 3
grabPermissions: PointerHandler.CanTakeOverFromItems
| PointerHandler.CanTakeOverFromHandlersOfDifferentType
onActiveChanged: {
if (active) {
outline.dragRow = del.index
} else {
outline.commitDrop()
outline.interactive = true // belt-and-suspenders
MouseArea {
id: gripMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: gripLabel.active ? Qt.ClosedHandCursor
: Qt.OpenHandCursor
preventStealing: true
property bool started: false
onPressed: started = false
onPositionChanged: (mouse) => {
if (started) return
started = true
// Record the drag source in the model (survives a
// later file switch) and begin the OS drag.
docModel.beginDrag(del.index)
outline.dragActive = true
dragProxy.Drag.mimeData = {
"text/plain": docModel.dragSourceDoc()
+ "\t" + docModel.dragSourceId()
}
dragProxy.Drag.active = true
dragProxy.Drag.startDrag()
}
onCentroidChanged: {
if (!active || outline.dragRow < 0) return
// Map the scene pointer position to ListView content
// coordinates. mapFromItem(null, ...) treats the
// source coordinates as scene coordinates.
var p = outline.contentItem.mapFromItem(
null,
centroid.scenePosition.x,
centroid.scenePosition.y)
outline.updateDrop(p.x, p.y)
onReleased: {
// If the drag never produced a drop, clean up.
if (outline.dragActive && !started) {
outline.dragActive = false
docModel.endDrag()
}
started = false
}
}
}

View File

@ -555,30 +555,6 @@ bool DocumentModel::demoteSection(int row)
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())
@ -586,28 +562,85 @@ QString DocumentModel::sectionIdAt(int row)
return m_visibleRows[row];
}
QVariantMap DocumentModel::resolveDrop(int draggedRow, int gapAboveRow, int desiredLevel)
QString DocumentModel::beginDrag(int row)
{
if (row < 0 || row >= m_visibleRows.size()) {
m_dragSourceDoc.clear();
m_dragSourceId.clear();
return QString();
}
m_dragSourceId = m_visibleRows[row];
m_dragSourceDoc = m_document;
if (m_dragSourceDoc.startsWith('/'))
m_dragSourceDoc = m_dragSourceDoc.mid(1);
return m_dragSourceId;
}
void DocumentModel::endDrag()
{
m_dragSourceDoc.clear();
m_dragSourceId.clear();
}
bool DocumentModel::commitDrag(const QString &parentId, const QString &beforeId)
{
if (!m_client || m_dragSourceDoc.isEmpty() || m_dragSourceId.isEmpty())
return false;
QString toDoc = m_document;
if (toDoc.startsWith('/'))
toDoc = toDoc.mid(1);
// refile <src-doc> <id> <to-doc> [parent-id] [before-id]. A "-" in the
// parent slot means top level (lets a before-id be given at top level).
const QString parent = parentId.isEmpty() ? QStringLiteral("-") : parentId;
QString cmd = QStringLiteral("refile %1 %2 %3")
.arg(m_dragSourceDoc, m_dragSourceId, toDoc);
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);
endDrag();
if (!reply.startsWith(QLatin1String("ok")))
return false;
reload();
return true;
}
QVariantMap DocumentModel::resolveDrop(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;
// Determine whether the drag source lives in the current view. If so, its
// subtree is excluded (can't drop into itself); for a cross-file drag the
// source isn't present, so there's nothing to exclude.
QString currentDoc = m_document;
if (currentDoc.startsWith('/'))
currentDoc = currentDoc.mid(1);
const bool sameDoc = (m_dragSourceDoc == currentDoc)
&& m_sections.contains(m_dragSourceId);
// 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;
int draggedRow = -1;
int dragEnd = -1; // exclusive end of the dragged subtree in visible order
if (sameDoc) {
draggedRow = m_visibleRows.indexOf(m_dragSourceId);
if (draggedRow >= 0) {
const int dragLevel = m_sections[m_dragSourceId].level;
dragEnd = draggedRow + 1;
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; };
auto inDragged = [&](int r) {
return draggedRow >= 0 && r >= draggedRow && r < dragEnd;
};
// Row A directly above the gap (skip the dragged subtree), and its level.
int aRow = gapAboveRow;

View File

@ -141,25 +141,38 @@ public:
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);
// ── Drag-and-drop refile (scene-level, cross-file capable) ───────────
// The drag source is captured independently of the currently-viewed
// document so the view can "spring-load" to another file mid-drag without
// losing the dragged subtree.
// Begin a drag: record (sourceDoc, sourceId) from the current view for the
// section at `row`. Returns the source id ("" if the row is invalid).
Q_INVOKABLE QString beginDrag(int row);
// The document and id captured by beginDrag (for the drag payload / UI).
Q_INVOKABLE QString dragSourceDoc() const { return m_dragSourceDoc; }
Q_INVOKABLE QString dragSourceId() const { return m_dragSourceId; }
// Resolve a drop point against the CURRENT view. `gapAboveRow` is the
// visible row directly above the insertion gap (-1 = top). `desiredLevel`
// is the heading level the pointer's indent aims at. Clamped to the gap's
// legal range. Returns { valid, level, parentId, parentTitle, beforeId }.
// The drag-source subtree is excluded only when it lives in the current
// document (so a subtree can't be dropped into itself); for a cross-file
// drag the source isn't present here, so nothing is excluded.
Q_INVOKABLE QVariantMap resolveDrop(int gapAboveRow, int desiredLevel);
// Commit the active drag: refile (sourceDoc, sourceId) into the CURRENT
// document under `parentId` (empty = top level), before `beforeId` (empty
// = append). Clears the drag state. Returns true on success.
Q_INVOKABLE bool commitDrag(const QString &parentId, const QString &beforeId);
// Abandon the active drag without changes.
Q_INVOKABLE void endDrag();
// 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
@ -250,6 +263,10 @@ private:
// Visible row order (section ids)
QStringList m_visibleRows;
// Active drag source (captured by beginDrag; survives view/doc switches).
QString m_dragSourceDoc;
QString m_dragSourceId;
// Supported src-block languages from `/ctl languages` (loaded once).
mutable QStringList m_supportedLanguages;
mutable bool m_languagesLoaded = false;