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)
|
||||
|
||||
### 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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -121,6 +121,15 @@ public:
|
|||
// /<doc>/<id>/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
|
||||
// /<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,
|
||||
// and return its visible row index (-1 if the id is unknown). Emits
|
||||
// sectionRevealed(row, bodyOffset) so the view can scroll to and highlight
|
||||
|
|
|
|||
Loading…
Reference in New Issue