Fix field clearing and gate drag behind press-and-hold

Two issues with the editing UI:

Clearing a keyword/priority did nothing. The "None" menu items write an
empty value to the field, which the client sends as an OTRUNC open plus a
zero-length Twrite. But the server flushed the virtfs write handler on
clunk only when the write buffer was non-empty, so the empty write — the
whole point of a clear — was dropped and the field kept its old value.
Flush on clunk whenever the fid was opened for writing (non-rdwr), not
just when bytes were buffered, so an empty write clears the field.
Verified live: an empty stdin write to /<doc>/<id>/keyword and /priority
now removes the keyword and priority from the headline.

Drag-to-refile fought the ListView. The grip's DragHandler and the
enclosing Flickable both wanted the press, so dragging usually scrolled
instead of moving the section. Gate the drag behind a press-and-hold: a
left-gutter grip (⠿ in a rounded cell) arms on a 0.35s long press
(TapHandler.onLongPressed), and the DragHandler is enabled only while
armed. A quick press-drag now scrolls the list; a hold then drag performs
the structural move. The grip highlights while armed/dragging and shows
an open-hand/size-all cursor.

GUI builds clean; server builds clean.
This commit is contained in:
Levi Neely 2026-10-05 09:19:24 +02:00
parent 0a06ca60bf
commit ed65235940
2 changed files with 51 additions and 20 deletions

View File

@ -506,8 +506,13 @@ impl Server {
let mut fids = self.fids.write().unwrap();
if let Some(f) = fids.remove(&fid) {
// Flush write buffer if not rdwr mode
if !f.write_buf.is_empty() && f.node.rdwr_fn.is_none() {
// Flush the write buffer on clunk for non-rdwr files. A writable
// open with an empty buffer is a legitimate "set to empty" (the
// client opened with OTRUNC and wrote nothing, e.g. clearing a
// field), so flush whenever the fid was opened for writing — not
// only when the buffer is non-empty.
let opened_for_write = f.open_mode.map(|m| m.is_writable()).unwrap_or(false);
if f.node.rdwr_fn.is_none() && (opened_for_write || !f.write_buf.is_empty()) {
if let Err(e) = f.node.write(&f.write_buf) {
return self.error_response(tag, &e.to_string());
}

View File

@ -26,6 +26,10 @@ ListView {
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)
// Row whose grip has been "armed" by a press-and-hold, so its DragHandler
// may start a structural drag. This gates the drag behind a hold so a
// quick press-drag still scrolls the ListView (Flickable) instead.
property int dragArmed: -1
// Recompute the drop target from a pointer position in content coords.
function updateDrop(contentX, contentY) {
@ -517,33 +521,54 @@ 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. A DragHandler is used
// (not a MouseArea) so the enclosing ListView cannot steal the
// drag for flicking.
Label {
// Drag grip (left gutter): press-and-hold to arm, then drag to
// restructure the section (and its subtree). A live indicator
// shows the resolved drop target; releasing commits a refile.
// Horizontal position chooses the depth, vertical the gap.
//
// Gating the drag behind a hold resolves the conflict with the
// ListView (a Flickable): a quick press-drag scrolls the list;
// a hold arms the grip so its DragHandler takes the grab.
Rectangle {
id: gripLabel
text: "⠿"
color: theme.placeholderTextColor
// A slightly larger hit target than the bare glyph.
Layout.preferredWidth: outline.baseFontSize + 6
horizontalAlignment: Text.AlignHCenter
font.pointSize: outline.baseFontSize - 1
opacity: gripHover.hovered || outline.dragRow === del.index ? 1.0 : 0.3
Layout.preferredWidth: outline.baseFontSize + 8
Layout.preferredHeight: outline.baseFontSize + 8
radius: 3
readonly property bool armed: outline.dragArmed === del.index
|| outline.dragRow === del.index
color: armed ? theme.highlightColor
: (gripHover.hovered ? theme.alternateBaseColor : "transparent")
Label {
anchors.centerIn: parent
text: "⠿"
color: gripLabel.armed ? theme.highlightedTextColor
: theme.placeholderTextColor
font.pointSize: outline.baseFontSize - 1
opacity: gripHover.hovered || gripLabel.armed ? 1.0 : 0.4
}
HoverHandler {
id: gripHover
cursorShape: Qt.SizeAllCursor
cursorShape: gripLabel.armed ? Qt.SizeAllCursor : Qt.OpenHandCursor
}
// Press-and-hold to arm the drag without moving.
TapHandler {
id: gripHold
longPressThreshold: 0.35 // seconds
onLongPressed: outline.dragArmed = del.index
// A plain tap (no hold) shouldn't arm anything.
onTapped: outline.dragArmed = -1
}
// The structural drag itself. Only grabs once armed, so the
// Flickable keeps scrolling for un-armed press-drags.
DragHandler {
id: gripDrag
target: null // we track, we don't move the item
target: null // track only; don't move the item
enabled: gripLabel.armed
dragThreshold: 3
// Take the grab and refuse to yield it to the ListView
// (a Flickable), which would otherwise steal the drag.
grabPermissions: PointerHandler.CanTakeOverFromItems
| PointerHandler.CanTakeOverFromHandlersOfDifferentType
@ -552,6 +577,7 @@ ListView {
outline.dragRow = del.index
} else {
outline.commitDrop()
outline.dragArmed = -1
}
}
onCentroidChanged: {