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:
parent
bfc0c80d09
commit
dc34f6467f
14
VISION.md
14
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] 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
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 §ionId, int bodyOffset)
|
int DocumentModel::revealSection(const QString §ionId, int bodyOffset)
|
||||||
{
|
{
|
||||||
if (sectionId.isEmpty() || !m_sections.contains(sectionId))
|
if (sectionId.isEmpty() || !m_sections.contains(sectionId))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue