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:
parent
741ca6d963
commit
9ed3b730e6
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -472,6 +472,38 @@ bool DocumentModel::setDeadline(int row, const QString ×tamp)
|
||||||
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 §ionId, int bodyOffset)
|
int DocumentModel::revealSection(const QString §ionId, int bodyOffset)
|
||||||
{
|
{
|
||||||
if (sectionId.isEmpty() || !m_sections.contains(sectionId))
|
if (sectionId.isEmpty() || !m_sections.contains(sectionId))
|
||||||
|
|
|
||||||
|
|
@ -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 ×tamp);
|
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
|
||||||
|
// /<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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue