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 /<doc>/keywords file.

GUI:
- DocumentModel::setKeyword writes /<doc>/<id>/keyword and refreshes the row;
  cycleKeyword reads the per-document /<doc>/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.
This commit is contained in:
Levi Neely 2026-10-02 12:37:28 +02:00
parent bfc0c80d09
commit dc34f6467f
6 changed files with 187 additions and 12 deletions

View File

@ -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] Structural ops via `ctl` (rm, move, promote, demote, refile, archive, lint)
- [x] `query` and agenda views (todos, today, week) - [x] `query` and agenda views (todos, today, week)
### v0.3 — Read-only UI ### v0.3 — Read-only UI ✅
- [ ] Qt/QML document view rendering the 9P namespace - [x] Qt/QML document view rendering the 9P namespace
- [ ] Headline folding/cycling - [x] Headline folding/cycling
- [ ] Structural styling (keywords, priorities, tags, timestamps as distinct objects) - [x] Structural styling (keywords, priorities, tags, timestamps as distinct objects)
- [ ] Navigation - [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 - [ ] In-place raw-text editing per section, reparse-on-commit
- [ ] Direct manipulation: click keyword/priority/tag/timestamp → 9P op - [ ] Direct manipulation: click keyword/priority/tag/timestamp → 9P op
(keyword cycling done; priority/tag/timestamp pending)
- [ ] Drag to move / refile / promote / demote - [ ] Drag to move / refile / promote / demote
- [ ] Checkbox toggling - [x] Checkbox toggling
- [x] Code block execution (brought forward from Future)
- [ ] Undo/redo - [ ] Undo/redo
### v0.5 — Agenda & search UI ### v0.5 — Agenda & search UI

View File

@ -162,6 +162,19 @@ fn build_doc_node(state: Arc<OrkState>, 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 // resolve - write a link target, read its structural resolution
// (kind + section id + offset). See OrkState::resolve_target. // (kind + section id + offset). See OrkState::resolve_target.
virtfs::file("resolve", 0o666, virtfs::rdwr({ virtfs::file("resolve", 0o666, virtfs::rdwr({

View File

@ -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 { pub fn keywords(&self) -> String {
// Return default TODO keywords "TODO | DONE\n".to_string()
// TODO: aggregate from all docs or use config }
"TODO NEXT WAITING | DONE CANCELLED\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<String> {
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<String> = Vec::new();
let mut done: Vec<String> = 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. /// Handle control commands via a self-describing dispatch table.
@ -2435,6 +2458,43 @@ mod tests {
assert_eq!(body, "Waiting on external input."); 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] #[test]
fn test_delete_section() { fn test_delete_section() {
let (_d, state) = state_with("#+TITLE: W\n* One\n* Two\n* Three\n"); let (_d, state) = state_with("#+TITLE: W\n* One\n* Two\n* Three\n");

View File

@ -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 { Rectangle {
visible: (del.model.keyword ?? "") !== "" visible: (del.model.keyword ?? "") !== ""
radius: 3 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.color: outline.keywordColor(del.model.keyword ?? "")
border.width: 1 border.width: 1
implicitWidth: kwLabel.implicitWidth + 10 implicitWidth: kwLabel.implicitWidth + 10
@ -417,6 +424,21 @@ ListView {
font.pointSize: outline.baseFontSize - 1 font.pointSize: outline.baseFontSize - 1
font.bold: true 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 // Priority cookie

View File

@ -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 /<doc>/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 &sectionId, int bodyOffset) int DocumentModel::revealSection(const QString &sectionId, int bodyOffset)
{ {
if (sectionId.isEmpty() || !m_sections.contains(sectionId)) if (sectionId.isEmpty() || !m_sections.contains(sectionId))

View File

@ -83,6 +83,16 @@ public:
// the section body back via /body and refreshes the row. Returns success. // the section body back via /body and refreshes the row. Returns success.
Q_INVOKABLE bool cycleCheckbox(int row, int checkboxOrdinal, bool forward); Q_INVOKABLE bool cycleCheckbox(int row, int checkboxOrdinal, bool forward);
// Set a section's TODO keyword directly (empty string removes it). Writes
// /<doc>/<id>/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, // 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