From dc34f6467f522af7a150cf6fba2fd974c6d8fcd5 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Fri, 2 Oct 2026 12:37:28 +0200 Subject: [PATCH] Cycle a TODO keyword by clicking its pill First direct-manipulation edit (v0.4): clicking a headline's keyword pill cycles its TODO state, each change a single 9P write. Left-click / wheel-up advance (TODO -> NEXT -> ... -> DONE -> none -> TODO); right-click / wheel-down step back. Per-document keyword set (server): - keywords() previously advertised a hardcoded "TODO NEXT WAITING | DONE CANCELLED", but the parser only recognizes NEXT/WAITING/CANCELLED when the document declares them via #+TODO:. Writing an unrecognized keyword merged it into the title and changed the section's slug id. - keywords() now returns the parser default "TODO | DONE"; new keywords_for(doc) returns a document's declared #+TODO: sequence or that default. Exposed as a per-document //keywords file. GUI: - DocumentModel::setKeyword writes ///keyword and refreshes the row; cycleKeyword reads the per-document //keywords so it only cycles to keywords the parser recognizes (keeping the section id stable), with a trailing empty state to clear the keyword. - The keyword pill gains a hover highlight and a MouseArea for click/wheel cycling. Verified: server tests (66 passed, +3: default keyword set, declared sequence, id stable across set+clear). Live 9P run cycling both a plain document (TODO|DONE) and one with a #+TODO: sequence confirms the section id stays stable through a full cycle and every transition writes correctly; empty-write clearing is covered by a unit test (the ork CLI can't pass an empty arg, but the GUI's p9 client sends a real empty write). GUI builds clean. Also refresh VISION.md milestones: v0.3 done; checkbox toggling, code execution, and keyword cycling ticked under v0.4. --- VISION.md | 14 +++--- crates/ork-server/src/namespace.rs | 13 ++++++ crates/ork-server/src/state.rs | 68 ++++++++++++++++++++++++++++-- ui/qml/OutlineView.qml | 26 +++++++++++- ui/src/documentmodel.cpp | 68 ++++++++++++++++++++++++++++++ ui/src/documentmodel.h | 10 +++++ 6 files changed, 187 insertions(+), 12 deletions(-) diff --git a/VISION.md b/VISION.md index 82f48d9..9712476 100644 --- a/VISION.md +++ b/VISION.md @@ -113,17 +113,19 @@ Every client — GUI, CLI, automation — speaks 9P to `ork-server`. The server - [x] Structural ops via `ctl` (rm, move, promote, demote, refile, archive, lint) - [x] `query` and agenda views (todos, today, week) -### v0.3 — Read-only UI -- [ ] Qt/QML document view rendering the 9P namespace -- [ ] Headline folding/cycling -- [ ] Structural styling (keywords, priorities, tags, timestamps as distinct objects) -- [ ] Navigation +### v0.3 — Read-only UI ✅ +- [x] Qt/QML document view rendering the 9P namespace +- [x] Headline folding/cycling +- [x] Structural styling (keywords, priorities, tags, timestamps as distinct objects) +- [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) - [ ] Drag to move / refile / promote / demote -- [ ] Checkbox toggling +- [x] Checkbox toggling +- [x] Code block execution (brought forward from Future) - [ ] Undo/redo ### v0.5 — Agenda & search UI diff --git a/crates/ork-server/src/namespace.rs b/crates/ork-server/src/namespace.rs index 9e153cc..e7c625d 100644 --- a/crates/ork-server/src/namespace.rs +++ b/crates/ork-server/src/namespace.rs @@ -162,6 +162,19 @@ fn build_doc_node(state: Arc, doc_name: &str) -> FsNode { } })), + // keywords - this document's effective TODO keyword set (its + // declared #+TODO: sequences, else the parser default TODO | DONE). + // Clients cycle a headline's keyword through exactly this set. + virtfs::file("keywords", 0o444, virtfs::read({ + let state = state.clone(); + let name = name.clone(); + move || { + state.keywords_for(&name) + .map(|s| s.into_bytes()) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found")) + } + })), + // resolve - write a link target, read its structural resolution // (kind + section id + offset). See OrkState::resolve_target. virtfs::file("resolve", 0o666, virtfs::rdwr({ diff --git a/crates/ork-server/src/state.rs b/crates/ork-server/src/state.rs index eb452a6..b7e1f53 100644 --- a/crates/ork-server/src/state.rs +++ b/crates/ork-server/src/state.rs @@ -240,11 +240,34 @@ impl OrkState { }) } - /// Get all TODO keywords configuration. + /// Global TODO keyword configuration. With no document context this is the + /// parser's built-in default (TODO | DONE) — the only keywords recognized + /// in a document that declares no `#+TODO:` sequence. Clients that need a + /// specific document's keywords (e.g. to cycle a headline) should read the + /// per-document keyword set via `keywords_for`. pub fn keywords(&self) -> String { - // Return default TODO keywords - // TODO: aggregate from all docs or use config - "TODO NEXT WAITING | DONE CANCELLED\n".to_string() + "TODO | DONE\n".to_string() + } + + /// The effective TODO keyword set for one document: its declared `#+TODO:` + /// (or `#+SEQ_TODO:`/`#+TYP_TODO:`) sequences if any, otherwise the parser + /// default `TODO | DONE`. Format matches `/keywords`: active states, then + /// `|`, then done states. This is exactly the set the parser will recognize + /// in that document, so a client can safely cycle a keyword through it. + pub fn keywords_for(&self, doc_name: &str) -> Option { + self.get_doc(doc_name).map(|d| { + let sets = &d.doc.settings.todo_keywords; + if sets.is_empty() { + return "TODO | DONE\n".to_string(); + } + let mut active: Vec = Vec::new(); + let mut done: Vec = Vec::new(); + for set in sets { + active.extend(set.active.iter().cloned()); + done.extend(set.done.iter().cloned()); + } + format!("{} | {}\n", active.join(" "), done.join(" ")) + }) } /// Handle control commands via a self-describing dispatch table. @@ -2435,6 +2458,43 @@ mod tests { assert_eq!(body, "Waiting on external input."); } + #[test] + fn keywords_for_defaults_to_todo_done() { + // No #+TODO: sequence: only the parser default is recognized. + let (_d, state) = state_with("#+TITLE: W\n* TODO Task\n"); + assert_eq!(state.keywords_for("w").unwrap(), "TODO | DONE\n"); + } + + #[test] + fn keywords_for_uses_declared_sequence() { + // A declared #+TODO: sequence drives the reported keyword set. + let (_d, state) = state_with( + "#+TITLE: W\n#+TODO: TODO NEXT WAITING | DONE CANCELLED\n* NEXT Task\n", + ); + assert_eq!( + state.keywords_for("w").unwrap(), + "TODO NEXT WAITING | DONE CANCELLED\n" + ); + } + + #[test] + fn set_keyword_keeps_section_id_for_recognized_keyword() { + // Cycling to a keyword the document recognizes must not change the + // section's slug id (the keyword is stripped from the title). + let (_d, state) = state_with( + "#+TITLE: W\n#+TODO: TODO NEXT | DONE\n* TODO First task\n", + ); + assert!(state.find_section(&state.get_doc("w").unwrap().doc, "first-task").is_some()); + state.set_section_keyword("w", "first-task", "NEXT").unwrap(); + // Same id still resolves; keyword updated. + let kw = state.section_keyword("w", "first-task").unwrap(); + assert_eq!(kw, "NEXT"); + // Clearing the keyword (empty write) removes it and keeps the id. + state.set_section_keyword("w", "first-task", "").unwrap(); + assert_eq!(state.section_keyword("w", "first-task").unwrap_or_default(), ""); + assert!(state.find_section(&state.get_doc("w").unwrap().doc, "first-task").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 ef630e4..c481a51 100644 --- a/ui/qml/OutlineView.qml +++ b/ui/qml/OutlineView.qml @@ -400,11 +400,18 @@ ListView { } } - // TODO keyword pill + // TODO keyword pill. Click cycles the keyword forward + // (TODO -> NEXT -> ... -> DONE -> none -> TODO); right-click or + // wheel cycles backward. Each change is one 9P write via the + // model, matching the "document is the control surface" model. Rectangle { visible: (del.model.keyword ?? "") !== "" radius: 3 - color: "transparent" + color: kwMouse.containsMouse ? Qt.rgba( + outline.keywordColor(del.model.keyword ?? "").r, + outline.keywordColor(del.model.keyword ?? "").g, + outline.keywordColor(del.model.keyword ?? "").b, 0.15) + : "transparent" border.color: outline.keywordColor(del.model.keyword ?? "") border.width: 1 implicitWidth: kwLabel.implicitWidth + 10 @@ -417,6 +424,21 @@ ListView { font.pointSize: outline.baseFontSize - 1 font.bold: true } + + MouseArea { + id: kwMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: (mouse) => { + docModel.cycleKeyword(del.index, mouse.button !== Qt.RightButton) + } + onWheel: (wheel) => { + docModel.cycleKeyword(del.index, wheel.angleDelta.y > 0) + wheel.accepted = true + } + } } // Priority cookie diff --git a/ui/src/documentmodel.cpp b/ui/src/documentmodel.cpp index af3ec2f..231c984 100644 --- a/ui/src/documentmodel.cpp +++ b/ui/src/documentmodel.cpp @@ -278,6 +278,74 @@ bool DocumentModel::cycleCheckbox(int row, int checkboxOrdinal, bool forward) }); } +bool DocumentModel::setKeyword(int row, const QString &keyword) +{ + 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("/keyword"), keyword)) + return false; + + // Refresh the section's fields so the model reflects the written headline. + sec.fieldsLoaded = false; + loadFields(sec); + + const QModelIndex idx = index(row); + emit dataChanged(idx, idx, {KeywordRole, IsTodoRole, IsDoneRole, TitleRole}); + return true; +} + +bool DocumentModel::cycleKeyword(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); + + // Build the cycle from this document's effective keyword set, then a + // trailing empty state so cycling can clear the keyword. Reading the + // per-document //keywords (not the global default) ensures we only + // cycle to keywords the parser recognizes for this document — otherwise an + // unrecognized keyword would merge into the title and change the section + // id. The set separates not-done and done states with '|', which is not a + // keyword. + QString doc = m_document; + if (doc.startsWith('/')) + doc = doc.mid(1); + const QString kwLine = m_client->read(QStringLiteral("/%1/keywords").arg(doc)); + QStringList states; + for (const QString &tok : kwLine.split(QRegularExpression(QStringLiteral("\\s+")), + Qt::SkipEmptyParts)) { + if (tok == QLatin1String("|")) + continue; + states.append(tok); + } + if (states.isEmpty()) + return false; + states.append(QString()); // trailing empty = "no keyword" + + // Find the current keyword's position; default to the empty state so the + // first forward step lands on the first keyword. + int cur = states.indexOf(sec.keyword); + if (cur < 0) + cur = states.size() - 1; // treat unknown as the empty state + + const int n = states.size(); + const int next = forward ? (cur + 1) % n : (cur - 1 + n) % n; + return setKeyword(row, states[next]); +} + 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 21e0b80..6b2b884 100644 --- a/ui/src/documentmodel.h +++ b/ui/src/documentmodel.h @@ -83,6 +83,16 @@ public: // the section body back via /body and refreshes the row. Returns success. Q_INVOKABLE bool cycleCheckbox(int row, int checkboxOrdinal, bool forward); + // Set a section's TODO keyword directly (empty string removes it). Writes + // ///keyword and refreshes the row. Returns true on success. + Q_INVOKABLE bool setKeyword(int row, const QString &keyword); + + // Cycle a section's TODO keyword through the configured sequence (from + // /keywords) plus a trailing empty state, e.g. + // (none) -> TODO -> NEXT -> WAITING -> DONE -> CANCELLED -> (none). + // `forward` advances; otherwise steps back. Returns true on success. + Q_INVOKABLE bool cycleKeyword(int row, bool forward); + // 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