Edit priority and tags by direct manipulation

Extend v0.4 direct manipulation to the priority cookie and tags, each a
single 9P write. Priority and tags are not part of the slug, so these
edits never change the section id.

Priority:
- DocumentModel::setPriority writes /<doc>/<id>/priority; cyclePriority
  steps (none) -> A -> B -> C -> (none). The priority cookie gains a
  MouseArea: left-click / wheel-up advance, right-click / wheel-down back.

Tags:
- DocumentModel::setTags writes /<doc>/<id>/tags from a free-form string
  (space/comma/colon separated; the server normalizes and colon-joins).
- The tag row becomes editable: clicking a pill or the "+" affordance
  opens an inline TextField prefilled with the current tags; Enter or
  focus-loss commits, Escape cancels.

Server fix:
- set_section_priority passed None to rebuild_headline for an empty
  priority, but rebuild_headline treats None as "keep the existing
  value", so clearing a priority silently did nothing. Pass Some("")
  explicitly, matching set_section_keyword. Found while testing the clear
  path.

Verified: server tests (68 passed, +2 for priority cycle/clear and tag
set/clear keeping the id, plus the priority-clear fix). Live 9P run
cycled priority (none/A/B/C) and set/replaced tags on a headline with the
id stable throughout. GUI builds clean.
This commit is contained in:
Levi Neely 2026-10-02 12:58:06 +02:00
parent dc34f6467f
commit d83324e1e4
4 changed files with 209 additions and 19 deletions

View File

@ -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");

View File

@ -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
}
}

View File

@ -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 &sectionId, int bodyOffset)
{
if (sectionId.isEmpty() || !m_sections.contains(sectionId))

View File

@ -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
// /<doc>/<id>/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 /<doc>/<id>/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