From 9ed3b730e6eb5c4e4942b2211d04de562c7a56e3 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Fri, 2 Oct 2026 15:18:08 +0200 Subject: [PATCH] Add in-place body editing with timestamp insertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ///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. --- VISION.md | 6 +- ui/qml/DatePickerDialog.qml | 46 +++++++++++- ui/qml/OutlineView.qml | 137 ++++++++++++++++++++++++++++++++++-- ui/src/documentmodel.cpp | 32 +++++++++ ui/src/documentmodel.h | 9 +++ 5 files changed, 220 insertions(+), 10 deletions(-) diff --git a/VISION.md b/VISION.md index 9712476..c2ae0e5 100644 --- a/VISION.md +++ b/VISION.md @@ -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) ### v0.4 — Editing (the editing model) -- [ ] In-place raw-text editing per section, reparse-on-commit -- [ ] Direct manipulation: click keyword/priority/tag/timestamp → 9P op - (keyword cycling done; priority/tag/timestamp pending) +- [x] In-place raw-text editing per section, reparse-on-commit +- [x] Direct manipulation: click keyword/priority/tag/timestamp → 9P op + (keyword, priority, tags, and SCHEDULED/DEADLINE timestamps) - [ ] Drag to move / refile / promote / demote - [x] Checkbox toggling - [x] Code block execution (brought forward from Future) diff --git a/ui/qml/DatePickerDialog.qml b/ui/qml/DatePickerDialog.qml index 1218c39..bff1dd7 100644 --- a/ui/qml/DatePickerDialog.qml +++ b/ui/qml/DatePickerDialog.qml @@ -25,6 +25,13 @@ Dialog { property int targetRow: -1 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. property int shownMonth: (new Date()).getMonth() // 0-11 property int shownYear: (new Date()).getFullYear() @@ -49,12 +56,21 @@ Dialog { // Open the picker for a given row/field, prefilled from currentValue. function open2(row, fieldName, currentValue) { + mode = "planning" targetRow = row field = fieldName parseValue(currentValue) 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 // dialog's controls. An empty/unparseable value leaves today selected and // the time/repeater empty. @@ -103,6 +119,12 @@ Dialog { function applySet() { var ts = buildTimestamp() if (ts.length === 0) { close(); return } + if (mode === "insert") { + var wrapped = insertActive ? "<" + ts + ">" : "[" + ts + "]" + timestampAccepted(wrapped) + close() + return + } if (field === "DEADLINE") docModel.setDeadline(targetRow, ts) else @@ -121,10 +143,12 @@ Dialog { contentItem: ColumnLayout { spacing: 10 - // Field selector: Scheduled vs Deadline. + // Mode-dependent selector: Scheduled/Deadline (planning) or + // Active/Inactive (insert). RowLayout { Layout.fillWidth: true spacing: 6 + visible: dpd.mode === "planning" RadioButton { text: qsTr("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. RowLayout { Layout.fillWidth: true @@ -233,6 +274,7 @@ Dialog { spacing: 6 Button { text: qsTr("Clear") + visible: dpd.mode === "planning" onClicked: dpd.applyClear() } Item { Layout.fillWidth: true } @@ -241,7 +283,7 @@ Dialog { onClicked: dpd.close() } Button { - text: qsTr("Set") + text: dpd.mode === "insert" ? qsTr("Insert") : qsTr("Set") highlighted: true onClicked: dpd.applySet() } diff --git a/ui/qml/OutlineView.qml b/ui/qml/OutlineView.qml index c52c4c1..9df527b 100644 --- a/ui/qml/OutlineView.qml +++ b/ui/qml/OutlineView.qml @@ -14,6 +14,10 @@ ListView { readonly property int baseFontSize: theme.fontSize > 0 ? theme.fontSize : 12 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. function keywordColor(kw) { switch (kw) { @@ -117,11 +121,19 @@ ListView { onTriggered: outline.flashRow = -1 } - // Shared date picker for SCHEDULED/DEADLINE editing. Opened from a - // headline's calendar button via datePicker.open2(row, field, value). + // Shared date picker for SCHEDULED/DEADLINE editing and body-timestamp + // 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 { id: datePicker parent: Overlay.overlay + onTimestampAccepted: (ts) => { + if (outline.insertTarget) { + outline.insertTarget.insert(outline.insertTarget.cursorPosition, ts) + outline.insertTarget.forceActiveFocus() + } + } } delegate: Rectangle { @@ -140,6 +152,10 @@ ListView { // field prefilled with the current tags. 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. Rectangle { 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 { - visible: del.sectionBody !== "" + visible: del.sectionBody !== "" || del.editingBody Layout.fillWidth: true 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 { 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 { 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() + } + } + } } } } diff --git a/ui/src/documentmodel.cpp b/ui/src/documentmodel.cpp index bb93e80..ca4b27e 100644 --- a/ui/src/documentmodel.cpp +++ b/ui/src/documentmodel.cpp @@ -472,6 +472,38 @@ bool DocumentModel::setDeadline(int row, const QString ×tamp) 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 §ionId, int bodyOffset) { if (sectionId.isEmpty() || !m_sections.contains(sectionId)) diff --git a/ui/src/documentmodel.h b/ui/src/documentmodel.h index 372a769..0866342 100644 --- a/ui/src/documentmodel.h +++ b/ui/src/documentmodel.h @@ -121,6 +121,15 @@ public: // ///deadline and refreshes the row. Q_INVOKABLE bool setDeadline(int row, const QString ×tamp); + // Replace a section's body text (the content after the headline, planning + // line, and property drawer). The server reparses on write. Writes + // ///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 ///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, // and return its visible row index (-1 if the id is unknown). Emits // sectionRevealed(row, bodyOffset) so the view can scroll to and highlight