diff --git a/crates/ork-server/src/state.rs b/crates/ork-server/src/state.rs index b7e1f53..e55735d 100644 --- a/crates/ork-server/src/state.rs +++ b/crates/ork-server/src/state.rs @@ -1218,10 +1218,12 @@ impl OrkState { let section = self.find_section(&doc_state.doc, id) .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?; + // Always pass Some(&priority): rebuild_headline treats None as "keep + // the existing priority", so an empty string (removal) must be passed + // explicitly as Some("") — mirroring set_section_keyword. Using None + // for empty here would silently leave the old priority in place. let priority = priority.trim().to_uppercase(); - let pri = if priority.is_empty() { None } else { Some(priority.as_str()) }; - - let new_headline = self.rebuild_headline(section, None, pri, None, None); + let new_headline = self.rebuild_headline(section, None, Some(&priority), None, None); let new_content = self.replace_headline(&doc_state.content, section, &new_headline)?; self.update_doc(doc_name, &doc_state.path, new_content) @@ -2495,6 +2497,32 @@ mod tests { assert!(state.find_section(&state.get_doc("w").unwrap().doc, "first-task").is_some()); } + #[test] + fn set_priority_cycles_and_clears_keeping_id() { + // Priority is not part of the slug, so setting/clearing it never + // changes the section id. Empty clears the priority. + let (_d, state) = state_with("#+TITLE: W\n* TODO Task one\n"); + state.set_section_priority("w", "task-one", "A").unwrap(); + assert_eq!(state.section_priority("w", "task-one").unwrap(), "A"); + state.set_section_priority("w", "task-one", "C").unwrap(); + assert_eq!(state.section_priority("w", "task-one").unwrap(), "C"); + state.set_section_priority("w", "task-one", "").unwrap(); + assert_eq!(state.section_priority("w", "task-one").unwrap_or_default(), ""); + assert!(state.find_section(&state.get_doc("w").unwrap().doc, "task-one").is_some()); + } + + #[test] + fn set_tags_accepts_free_form_and_clears() { + // Tags may be space-separated on input and are stored colon-joined. + // An empty write removes all tags. The id is unaffected. + let (_d, state) = state_with("#+TITLE: W\n* TODO Task one\n"); + state.set_section_tags("w", "task-one", "work urgent home").unwrap(); + assert_eq!(state.section_tags("w", "task-one").unwrap(), "work:urgent:home"); + state.set_section_tags("w", "task-one", "").unwrap(); + assert_eq!(state.section_tags("w", "task-one").unwrap_or_default(), ""); + assert!(state.find_section(&state.get_doc("w").unwrap().doc, "task-one").is_some()); + } + #[test] fn test_delete_section() { let (_d, state) = state_with("#+TITLE: W\n* One\n* Two\n* Three\n"); diff --git a/ui/qml/OutlineView.qml b/ui/qml/OutlineView.qml index c481a51..653143f 100644 --- a/ui/qml/OutlineView.qml +++ b/ui/qml/OutlineView.qml @@ -129,6 +129,10 @@ ListView { property bool drawerOpen: false + // Inline tag editing: when true, the tag pills are replaced by a text + // field prefilled with the current tags. + property bool editingTags: false + // Transient flash overlay when this row is revealed via a link. Rectangle { anchors.fill: parent @@ -441,13 +445,29 @@ ListView { } } - // Priority cookie + // Priority cookie. Click cycles (none) -> A -> B -> C -> (none); + // right-click / wheel-down steps back. One 9P write per change. Label { + id: prioLabel visible: (del.model.priority ?? "") !== "" text: "[#" + (del.model.priority ?? "") + "]" color: outline.priorityColor(del.model.priority ?? "") font.pointSize: outline.baseFontSize font.bold: true + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: (mouse) => { + docModel.cyclePriority(del.index, mouse.button !== Qt.RightButton) + } + onWheel: (wheel) => { + docModel.cyclePriority(del.index, wheel.angleDelta.y > 0) + wheel.accepted = true + } + } } // Title. Rendered as StyledText so inline org links in the @@ -486,23 +506,86 @@ ListView { font.pointSize: outline.baseFontSize - 1 } - // Tags - Repeater { - model: del.model.tags ?? [] - delegate: Rectangle { - required property string modelData - radius: 3 - color: theme.alternateBaseColor - implicitWidth: tagLabel.implicitWidth + 8 - implicitHeight: tagLabel.implicitHeight + 3 - Label { - id: tagLabel - anchors.centerIn: parent - text: parent.modelData - color: theme.tagColor - font.pointSize: outline.baseFontSize - 2 + // Tags. In display mode, each tag is a pill and clicking any + // pill (or the "+" affordance) opens an inline editor. In edit + // mode, a text field holds the space/colon/comma-separated tags; + // Enter or focus-loss commits via one 9P write, Escape cancels. + RowLayout { + spacing: 4 + visible: !del.editingTags + + Repeater { + model: del.model.tags ?? [] + delegate: Rectangle { + required property string modelData + radius: 3 + color: tagMouse.containsMouse ? theme.highlightColor + : theme.alternateBaseColor + implicitWidth: tagLabel.implicitWidth + 8 + implicitHeight: tagLabel.implicitHeight + 3 + Label { + id: tagLabel + anchors.centerIn: parent + text: parent.modelData + color: theme.tagColor + font.pointSize: outline.baseFontSize - 2 + } + MouseArea { + id: tagMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: del.editingTags = true + } } } + + // "+" affordance to add/edit tags (always available). + Label { + text: "+" + color: theme.placeholderTextColor + font.pointSize: outline.baseFontSize - 1 + opacity: addTagMouse.containsMouse ? 1.0 : 0.5 + MouseArea { + id: addTagMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: del.editingTags = true + } + } + } + + TextField { + id: tagsEditor + visible: del.editingTags + Layout.preferredWidth: outline.baseFontSize * 14 + placeholderText: qsTr("tag1 tag2 …") + font.pointSize: outline.baseFontSize - 1 + selectByMouse: true + + // Prefill with the current tags whenever edit mode opens. + onVisibleChanged: { + if (visible) { + text = (del.model.tags ?? []).join(" ") + forceActiveFocus() + selectAll() + } + } + + function commit() { + docModel.setTags(del.index, text.trim()) + del.editingTags = false + } + + onAccepted: commit() + // Commit when focus is lost (click elsewhere), unless the + // editor was already closed (e.g. via Escape). + onActiveFocusChanged: { + if (!activeFocus && del.editingTags) + commit() + } + Keys.onEscapePressed: del.editingTags = false } } diff --git a/ui/src/documentmodel.cpp b/ui/src/documentmodel.cpp index 231c984..09aa848 100644 --- a/ui/src/documentmodel.cpp +++ b/ui/src/documentmodel.cpp @@ -346,6 +346,72 @@ bool DocumentModel::cycleKeyword(int row, bool forward) return setKeyword(row, states[next]); } +bool DocumentModel::setPriority(int row, const QString &priority) +{ + if (!m_client || row < 0 || row >= m_visibleRows.size()) + return false; + + const QString &id = m_visibleRows[row]; + if (!m_sections.contains(id)) + return false; + + Section &sec = m_sections[id]; + if (!m_client->write(sec.path + QStringLiteral("/priority"), priority)) + return false; + + sec.fieldsLoaded = false; + loadFields(sec); + + const QModelIndex idx = index(row); + emit dataChanged(idx, idx, {PriorityRole}); + return true; +} + +bool DocumentModel::cyclePriority(int row, bool forward) +{ + if (!m_client || row < 0 || row >= m_visibleRows.size()) + return false; + + const QString &id = m_visibleRows[row]; + if (!m_sections.contains(id)) + return false; + + Section &sec = m_sections[id]; + if (!sec.fieldsLoaded) + loadFields(sec); + + // Cycle: (none) -> A -> B -> C -> (none). + static const QStringList states = {QString(), QStringLiteral("A"), + QStringLiteral("B"), QStringLiteral("C")}; + int cur = states.indexOf(sec.priority); + if (cur < 0) + cur = 0; // treat unknown as the empty state + const int n = states.size(); + const int next = forward ? (cur + 1) % n : (cur - 1 + n) % n; + return setPriority(row, states[next]); +} + +bool DocumentModel::setTags(int row, const QString &tags) +{ + if (!m_client || row < 0 || row >= m_visibleRows.size()) + return false; + + const QString &id = m_visibleRows[row]; + if (!m_sections.contains(id)) + return false; + + Section &sec = m_sections[id]; + if (!m_client->write(sec.path + QStringLiteral("/tags"), tags)) + return false; + + sec.fieldsLoaded = false; + loadFields(sec); + + const QModelIndex idx = index(row); + emit dataChanged(idx, idx, {TagsRole}); + 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 6b2b884..2f97cfe 100644 --- a/ui/src/documentmodel.h +++ b/ui/src/documentmodel.h @@ -93,6 +93,19 @@ public: // `forward` advances; otherwise steps back. Returns true on success. Q_INVOKABLE bool cycleKeyword(int row, bool forward); + // Set a section's priority directly (A/B/C, or empty to remove). Writes + // ///priority and refreshes the row. Returns true on success. + Q_INVOKABLE bool setPriority(int row, const QString &priority); + + // Cycle a section's priority through (none) -> A -> B -> C -> (none). + // `forward` advances; otherwise steps back. Returns true on success. + Q_INVOKABLE bool cyclePriority(int row, bool forward); + + // Set a section's tags from a free-form string. Tags may be separated by + // spaces, commas, or colons; the server normalizes them. An empty string + // removes all tags. Writes ///tags and refreshes the row. + Q_INVOKABLE bool setTags(int row, const QString &tags); + // 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