Add in-place body editing with timestamp insertion

Click "⤤ edit" on a section body to replace the rendered segments with a
multi-line editor seeded from the raw body text. Save writes the body
back; the server reparses on commit. Cancel/Escape discards; Ctrl+Enter
saves.

Model:
- DocumentModel::bodyText(row) returns the raw /body for seeding the
  editor; setBody(row, text) writes /<doc>/<id>/body and reload()s, since
  a body edit can change the section's substructure.

Body editor (OutlineView):
- The body block now shows while editing even when empty, so an empty
  body can be opened. The segment Repeater is gated off (empty model)
  while editing and a TextArea in a ScrollView takes its place, with a
  toolbar (Insert timestamp…, Cancel, Save).

Timestamp-at-cursor insertion:
- DatePickerDialog gains an "insert" mode: openInsert() shows an
  Active <>/Inactive [] selector and, on Insert, emits timestampAccepted
  with the built timestamp instead of writing a planning field. The
  shared picker inserts it at the body editor's cursor (via the view's
  insertTarget).

The server's set_section_body already writes only the region after the
headline/planning/properties, so planning lines and property drawers are
preserved across a body edit.

Verified over 9P: body round-trips (seed read, multi-line write with an
inserted <...> timestamp and a checkbox item, read back), the section and
its siblings stay intact, and a section with SCHEDULED + a property
drawer keeps both after a body edit. GUI builds clean. VISION v0.4
updated: in-place editing and keyword/priority/tag/timestamp manipulation
done.
This commit is contained in:
Levi Neely 2026-10-02 15:18:08 +02:00
parent 741ca6d963
commit 9ed3b730e6
5 changed files with 220 additions and 10 deletions

View File

@ -120,9 +120,9 @@ Every client — GUI, CLI, automation — speaks 9P to `ork-server`. The server
- [x] Navigation (link following, section reveal with segment-precise scroll) - [x] Navigation (link following, section reveal with segment-precise scroll)
### v0.4 — Editing (the editing model) ### v0.4 — Editing (the editing model)
- [ ] In-place raw-text editing per section, reparse-on-commit - [x] In-place raw-text editing per section, reparse-on-commit
- [ ] Direct manipulation: click keyword/priority/tag/timestamp → 9P op - [x] Direct manipulation: click keyword/priority/tag/timestamp → 9P op
(keyword cycling done; priority/tag/timestamp pending) (keyword, priority, tags, and SCHEDULED/DEADLINE timestamps)
- [ ] Drag to move / refile / promote / demote - [ ] Drag to move / refile / promote / demote
- [x] Checkbox toggling - [x] Checkbox toggling
- [x] Code block execution (brought forward from Future) - [x] Code block execution (brought forward from Future)

View File

@ -25,6 +25,13 @@ Dialog {
property int targetRow: -1 property int targetRow: -1
property string field: "SCHEDULED" property string field: "SCHEDULED"
// Mode: "planning" writes a SCHEDULED/DEADLINE field; "insert" emits the
// built timestamp for a caller to insert (e.g. into a body editor at the
// cursor). In insert mode `insertActive` chooses <...> vs [...].
property string mode: "planning"
property bool insertActive: true
signal timestampAccepted(string ts)
// Calendar state. // Calendar state.
property int shownMonth: (new Date()).getMonth() // 0-11 property int shownMonth: (new Date()).getMonth() // 0-11
property int shownYear: (new Date()).getFullYear() property int shownYear: (new Date()).getFullYear()
@ -49,12 +56,21 @@ Dialog {
// Open the picker for a given row/field, prefilled from currentValue. // Open the picker for a given row/field, prefilled from currentValue.
function open2(row, fieldName, currentValue) { function open2(row, fieldName, currentValue) {
mode = "planning"
targetRow = row targetRow = row
field = fieldName field = fieldName
parseValue(currentValue) parseValue(currentValue)
open() open()
} }
// Open the picker in insert mode. On Set it emits timestampAccepted with
// the built <...> (or [...]) timestamp instead of writing a field.
function openInsert() {
mode = "insert"
parseValue("")
open()
}
// Parse an org timestamp like "<2025-01-15 Wed 10:30 +1w>" into the // Parse an org timestamp like "<2025-01-15 Wed 10:30 +1w>" into the
// dialog's controls. An empty/unparseable value leaves today selected and // dialog's controls. An empty/unparseable value leaves today selected and
// the time/repeater empty. // the time/repeater empty.
@ -103,6 +119,12 @@ Dialog {
function applySet() { function applySet() {
var ts = buildTimestamp() var ts = buildTimestamp()
if (ts.length === 0) { close(); return } if (ts.length === 0) { close(); return }
if (mode === "insert") {
var wrapped = insertActive ? "<" + ts + ">" : "[" + ts + "]"
timestampAccepted(wrapped)
close()
return
}
if (field === "DEADLINE") if (field === "DEADLINE")
docModel.setDeadline(targetRow, ts) docModel.setDeadline(targetRow, ts)
else else
@ -121,10 +143,12 @@ Dialog {
contentItem: ColumnLayout { contentItem: ColumnLayout {
spacing: 10 spacing: 10
// Field selector: Scheduled vs Deadline. // Mode-dependent selector: Scheduled/Deadline (planning) or
// Active/Inactive (insert).
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 6 spacing: 6
visible: dpd.mode === "planning"
RadioButton { RadioButton {
text: qsTr("Scheduled") text: qsTr("Scheduled")
checked: dpd.field === "SCHEDULED" checked: dpd.field === "SCHEDULED"
@ -137,6 +161,23 @@ Dialog {
} }
} }
// Insert-mode selector: active <...> vs inactive [...].
RowLayout {
Layout.fillWidth: true
spacing: 6
visible: dpd.mode === "insert"
RadioButton {
text: qsTr("Active <>")
checked: dpd.insertActive
onClicked: dpd.insertActive = true
}
RadioButton {
text: qsTr("Inactive []")
checked: !dpd.insertActive
onClicked: dpd.insertActive = false
}
}
// Month/year navigation. // Month/year navigation.
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
@ -233,6 +274,7 @@ Dialog {
spacing: 6 spacing: 6
Button { Button {
text: qsTr("Clear") text: qsTr("Clear")
visible: dpd.mode === "planning"
onClicked: dpd.applyClear() onClicked: dpd.applyClear()
} }
Item { Layout.fillWidth: true } Item { Layout.fillWidth: true }
@ -241,7 +283,7 @@ Dialog {
onClicked: dpd.close() onClicked: dpd.close()
} }
Button { Button {
text: qsTr("Set") text: dpd.mode === "insert" ? qsTr("Insert") : qsTr("Set")
highlighted: true highlighted: true
onClicked: dpd.applySet() onClicked: dpd.applySet()
} }

View File

@ -14,6 +14,10 @@ ListView {
readonly property int baseFontSize: theme.fontSize > 0 ? theme.fontSize : 12 readonly property int baseFontSize: theme.fontSize > 0 ? theme.fontSize : 12
readonly property int indentWidth: 20 readonly property int indentWidth: 20
// When a body editor requests a timestamp insertion, it registers itself
// here so the shared date picker can insert into it at the cursor.
property var insertTarget: null
// Palette for TODO keywords by state. // Palette for TODO keywords by state.
function keywordColor(kw) { function keywordColor(kw) {
switch (kw) { switch (kw) {
@ -117,11 +121,19 @@ ListView {
onTriggered: outline.flashRow = -1 onTriggered: outline.flashRow = -1
} }
// Shared date picker for SCHEDULED/DEADLINE editing. Opened from a // Shared date picker for SCHEDULED/DEADLINE editing and body-timestamp
// headline's calendar button via datePicker.open2(row, field, value). // insertion. Opened via datePicker.open2(row, field, value) for planning
// fields, or datePicker.openInsert() with outline.insertTarget set for
// inserting a timestamp into a body editor at the cursor.
DatePickerDialog { DatePickerDialog {
id: datePicker id: datePicker
parent: Overlay.overlay parent: Overlay.overlay
onTimestampAccepted: (ts) => {
if (outline.insertTarget) {
outline.insertTarget.insert(outline.insertTarget.cursorPosition, ts)
outline.insertTarget.forceActiveFocus()
}
}
} }
delegate: Rectangle { delegate: Rectangle {
@ -140,6 +152,10 @@ ListView {
// field prefilled with the current tags. // field prefilled with the current tags.
property bool editingTags: false property bool editingTags: false
// Inline body editing: when true, the rendered body segments are
// replaced by a multi-line text editor seeded with the raw body.
property bool editingBody: false
// Transient flash overlay when this row is revealed via a link. // Transient flash overlay when this row is revealed via a link.
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
@ -760,15 +776,40 @@ ListView {
} }
} }
// Body content (text and code blocks) // Body content (rendered segments, or an inline editor). The whole
// block is shown when there is body text or while editing (so an
// empty body can still be opened for editing via the ⤤ affordance).
ColumnLayout { ColumnLayout {
visible: del.sectionBody !== "" visible: del.sectionBody !== "" || del.editingBody
Layout.fillWidth: true Layout.fillWidth: true
spacing: 4 spacing: 4
// Edit affordance (shown on the display side). Opens the inline
// body editor seeded with the raw body text.
RowLayout {
Layout.fillWidth: true
visible: !del.editingBody
Item { Layout.fillWidth: true }
Label {
text: "⤤ edit"
color: theme.placeholderTextColor
font.pointSize: outline.baseFontSize - 2
opacity: editBodyMouse.containsMouse ? 1.0 : 0.4
MouseArea {
id: editBodyMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: del.editingBody = true
}
}
}
Repeater { Repeater {
id: bodyRepeater id: bodyRepeater
model: del.bodyParts // Hidden while editing: an empty model removes the rendered
// segments so the editor below takes their place.
model: del.editingBody ? [] : del.bodyParts
delegate: Loader { delegate: Loader {
required property var modelData required property var modelData
@ -1045,6 +1086,92 @@ ListView {
} }
} }
} }
// Inline body editor. Replaces the rendered segments while
// editing. Seeded with the raw body text; a toolbar inserts a
// timestamp at the cursor; Save writes via setBody (one 9P
// write, server reparses), Cancel/Escape discards.
ColumnLayout {
id: bodyEditColumn
visible: del.editingBody
Layout.fillWidth: true
spacing: 4
function saveBody() {
docModel.setBody(del.index, bodyEditor.text)
del.editingBody = false
}
ScrollView {
Layout.fillWidth: true
Layout.preferredHeight: Math.min(
Math.max(bodyEditor.contentHeight + 16, outline.baseFontSize * 6),
outline.baseFontSize * 20)
clip: true
TextArea {
id: bodyEditor
wrapMode: TextArea.Wrap
font.family: "monospace"
font.pointSize: outline.baseFontSize
selectByMouse: true
background: Rectangle {
color: theme.baseColor
border.color: theme.midColor
border.width: 1
radius: 3
}
// Seed from the raw body when the editor opens.
onVisibleChanged: {
if (visible) {
text = docModel.bodyText(del.index)
forceActiveFocus()
cursorPosition = text.length
}
}
// Ctrl+Enter saves; Escape cancels.
Keys.onPressed: (ev) => {
if ((ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter)
&& (ev.modifiers & Qt.ControlModifier)) {
bodyEditColumn.saveBody()
ev.accepted = true
} else if (ev.key === Qt.Key_Escape) {
del.editingBody = false
ev.accepted = true
}
}
}
}
// Editor toolbar.
RowLayout {
Layout.fillWidth: true
spacing: 6
Button {
text: qsTr("Insert timestamp…")
onClicked: {
outline.insertTarget = bodyEditor
datePicker.openInsert()
}
}
Item { Layout.fillWidth: true }
Label {
text: qsTr("Ctrl+Enter to save, Esc to cancel")
color: theme.placeholderTextColor
font.pointSize: outline.baseFontSize - 3
}
Button {
text: qsTr("Cancel")
onClicked: del.editingBody = false
}
Button {
text: qsTr("Save")
highlighted: true
onClicked: bodyEditColumn.saveBody()
}
}
}
} }
} }
} }

View File

@ -472,6 +472,38 @@ bool DocumentModel::setDeadline(int row, const QString &timestamp)
return true; return true;
} }
QString DocumentModel::bodyText(int row)
{
if (!m_client || row < 0 || row >= m_visibleRows.size())
return QString();
const QString &id = m_visibleRows[row];
if (!m_sections.contains(id))
return QString();
Section &sec = m_sections[id];
if (!sec.bodyLoaded)
loadBody(sec);
return sec.body;
}
bool DocumentModel::setBody(int row, const QString &body)
{
if (!m_client || row < 0 || row >= m_visibleRows.size())
return false;
const QString &id = m_visibleRows[row];
if (!m_sections.contains(id))
return false;
const Section &sec = m_sections[id];
if (!m_client->write(sec.path + QStringLiteral("/body"), body))
return false;
// Editing the body text can change the section's substructure (e.g. a new
// headline typed in), so reparse and rebuild the model rather than patching
// a single row.
reload();
return true;
}
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

@ -121,6 +121,15 @@ public:
// /<doc>/<id>/deadline and refreshes the row. // /<doc>/<id>/deadline and refreshes the row.
Q_INVOKABLE bool setDeadline(int row, const QString &timestamp); Q_INVOKABLE bool setDeadline(int row, const QString &timestamp);
// Replace a section's body text (the content after the headline, planning
// line, and property drawer). The server reparses on write. Writes
// /<doc>/<id>/body and refreshes the row. Returns true on success.
Q_INVOKABLE bool setBody(int row, const QString &body);
// The current raw body text of a section (as returned by /<doc>/<id>/body),
// for seeding an inline editor. Empty if the row or section is unknown.
Q_INVOKABLE QString bodyText(int row);
// 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