From 55856707c750e3a829ce084791ba01d90b6caac1 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 1 Oct 2026 15:45:26 +0200 Subject: [PATCH] Make GUI checkboxes clickable Clicking a checkbox glyph toggles its state and writes the change back. DocumentModel::toggleCheckbox(row, ordinal) finds the Nth checkbox cookie in the section body (document order), flips [ ]/[-] -> [X] and [X] -> [ ], writes the body via /body, and refreshes the row. The body parser assigns each checkbox item a stable ordinal across the whole section, and the list delegate wires a pointer-cursor MouseArea on the glyph (checkbox items only) to the model toggle. --- ui/qml/OutlineView.qml | 27 +++++++++++++++--- ui/src/documentmodel.cpp | 61 ++++++++++++++++++++++++++++++++++++++++ ui/src/documentmodel.h | 6 ++++ 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/ui/qml/OutlineView.qml b/ui/qml/OutlineView.qml index f7923db..e25b08f 100644 --- a/ui/qml/OutlineView.qml +++ b/ui/qml/OutlineView.qml @@ -94,7 +94,9 @@ ListView { // Push a run of text, splitting out any contiguous list items into // dedicated "list" parts so they render with bullets/checkboxes. - function pushText(text) { + // `cbCounter` is a single-element array used as a mutable counter so + // checkbox ordinals are stable across the whole section body. + function pushText(text, cbCounter) { if (!text) return; let lines = text.split('\n'); let buf = []; @@ -116,6 +118,9 @@ ListView { let item = parseListItem(ln); if (item) { flushBuf(); + // Assign a stable ordinal to checkbox-bearing items. + item.checkboxOrdinal = (item.checkbox !== "") + ? cbCounter[0]++ : -1; items.push(item); } else { flushItems(); @@ -126,6 +131,9 @@ ListView { flushBuf(); } + // Running checkbox ordinal across the whole section body. + let cbCounter = [0]; + let lines = body.split('\n'); let currentText = []; let inCode = false; @@ -143,7 +151,7 @@ ListView { if (!inCode && !inTable && trimmed.toUpperCase().startsWith("#+BEGIN_SRC")) { // Flush pending text if (currentText.length > 0) { - pushText(currentText.join('\n').trim()); + pushText(currentText.join('\n').trim(), cbCounter); currentText = []; } inCode = true; @@ -162,7 +170,7 @@ ListView { else if (!inTable && trimmed.startsWith("|")) { // Flush pending text if (currentText.length > 0) { - pushText(currentText.join('\n').trim()); + pushText(currentText.join('\n').trim(), cbCounter); currentText = []; } inTable = true; @@ -212,7 +220,7 @@ ListView { } parts.push({type: "table", rows: rows, hasHeader: hasHeader}); } else if (currentText.length > 0) { - pushText(currentText.join('\n').trim()); + pushText(currentText.join('\n').trim(), cbCounter); } return parts; @@ -447,6 +455,7 @@ ListView { // Checkbox glyph, or a plain bullet when no checkbox. Label { + id: cbGlyph readonly property string cb: modelData.checkbox ?? "" text: { switch (cb) { @@ -463,6 +472,16 @@ ListView { : theme.placeholderTextColor font.pointSize: outline.baseFontSize + 1 font.bold: cb !== "" + + // Clickable only for checkbox items; toggles via the model. + MouseArea { + anchors.fill: parent + enabled: cbGlyph.cb !== "" + visible: enabled + cursorShape: Qt.PointingHandCursor + onClicked: docModel.toggleCheckbox( + del.index, modelData.checkboxOrdinal ?? -1) + } } // Item text; checked items are dimmed and struck through. diff --git a/ui/src/documentmodel.cpp b/ui/src/documentmodel.cpp index e735da3..afc7e5d 100644 --- a/ui/src/documentmodel.cpp +++ b/ui/src/documentmodel.cpp @@ -2,6 +2,7 @@ #include "orkclient.h" #include +#include static const QStringList DONE_KEYWORDS = {"DONE", "CANCELLED", "CANCELED"}; @@ -186,6 +187,66 @@ bool DocumentModel::isLanguageSupported(const QString &lang) return m_supportedLanguages.contains(lang.trimmed().toLower()); } +bool DocumentModel::toggleCheckbox(int row, int checkboxOrdinal) +{ + 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.bodyLoaded) + loadBody(sec); + + // Match a list item's checkbox cookie: leading indent, a bullet (- / +) or + // ordered marker (N. / N)), then a [ ]/[x]/[X]/[-] cookie. + static const QRegularExpression cookieRe( + QStringLiteral("^(\\s*(?:[-+]|\\d+[.)])\\s+\\[)([ xX-])(\\])")); + + const QStringList lines = sec.body.split('\n'); + QStringList out; + out.reserve(lines.size()); + + int seen = -1; + bool toggled = false; + for (const QString &line : lines) { + QRegularExpressionMatch m = cookieRe.match(line); + if (m.hasMatch()) { + ++seen; + if (seen == checkboxOrdinal) { + const QString state = m.captured(2); + const QString next = (state == QLatin1String("X") || + state == QLatin1String("x")) + ? QStringLiteral(" ") + : QStringLiteral("X"); + QString updated = line; + updated.replace(m.capturedStart(2), m.capturedLength(2), next); + out.append(updated); + toggled = true; + continue; + } + } + out.append(line); + } + + if (!toggled) + return false; + + const QString newBody = out.join('\n'); + if (!m_client->write(sec.path + QStringLiteral("/body"), newBody)) + return false; + + // Refresh from the server so the parsed state matches what was written. + sec.bodyLoaded = false; + loadBody(sec); + + const QModelIndex idx = index(row); + emit dataChanged(idx, idx, {BodyRole}); + return true; +} + void DocumentModel::loadDocument() { beginResetModel(); diff --git a/ui/src/documentmodel.h b/ui/src/documentmodel.h index e9968df..dd56dd2 100644 --- a/ui/src/documentmodel.h +++ b/ui/src/documentmodel.h @@ -70,6 +70,12 @@ public: // `/ctl languages` (loaded once and cached). Case-insensitive. Q_INVOKABLE bool isLanguageSupported(const QString &lang); + // Toggle the Nth checkbox (0-based, in document order, counting only list + // items that carry a `[ ]`/`[X]`/`[-]` cookie) in the section at the given + // visible row. Flips [ ]/[-] -> [X] and [X] -> [ ], writes the section body + // back via /body, and refreshes the row. Returns true on success. + Q_INVOKABLE bool toggleCheckbox(int row, int checkboxOrdinal); + signals: void documentChanged();