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.
This commit is contained in:
Levi Neely 2026-10-01 15:45:26 +02:00
parent 754051b3a4
commit 55856707c7
3 changed files with 90 additions and 4 deletions

View File

@ -94,7 +94,9 @@ ListView {
// Push a run of text, splitting out any contiguous list items into // Push a run of text, splitting out any contiguous list items into
// dedicated "list" parts so they render with bullets/checkboxes. // 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; if (!text) return;
let lines = text.split('\n'); let lines = text.split('\n');
let buf = []; let buf = [];
@ -116,6 +118,9 @@ ListView {
let item = parseListItem(ln); let item = parseListItem(ln);
if (item) { if (item) {
flushBuf(); flushBuf();
// Assign a stable ordinal to checkbox-bearing items.
item.checkboxOrdinal = (item.checkbox !== "")
? cbCounter[0]++ : -1;
items.push(item); items.push(item);
} else { } else {
flushItems(); flushItems();
@ -126,6 +131,9 @@ ListView {
flushBuf(); flushBuf();
} }
// Running checkbox ordinal across the whole section body.
let cbCounter = [0];
let lines = body.split('\n'); let lines = body.split('\n');
let currentText = []; let currentText = [];
let inCode = false; let inCode = false;
@ -143,7 +151,7 @@ ListView {
if (!inCode && !inTable && trimmed.toUpperCase().startsWith("#+BEGIN_SRC")) { if (!inCode && !inTable && trimmed.toUpperCase().startsWith("#+BEGIN_SRC")) {
// Flush pending text // Flush pending text
if (currentText.length > 0) { if (currentText.length > 0) {
pushText(currentText.join('\n').trim()); pushText(currentText.join('\n').trim(), cbCounter);
currentText = []; currentText = [];
} }
inCode = true; inCode = true;
@ -162,7 +170,7 @@ ListView {
else if (!inTable && trimmed.startsWith("|")) { else if (!inTable && trimmed.startsWith("|")) {
// Flush pending text // Flush pending text
if (currentText.length > 0) { if (currentText.length > 0) {
pushText(currentText.join('\n').trim()); pushText(currentText.join('\n').trim(), cbCounter);
currentText = []; currentText = [];
} }
inTable = true; inTable = true;
@ -212,7 +220,7 @@ ListView {
} }
parts.push({type: "table", rows: rows, hasHeader: hasHeader}); parts.push({type: "table", rows: rows, hasHeader: hasHeader});
} else if (currentText.length > 0) { } else if (currentText.length > 0) {
pushText(currentText.join('\n').trim()); pushText(currentText.join('\n').trim(), cbCounter);
} }
return parts; return parts;
@ -447,6 +455,7 @@ ListView {
// Checkbox glyph, or a plain bullet when no checkbox. // Checkbox glyph, or a plain bullet when no checkbox.
Label { Label {
id: cbGlyph
readonly property string cb: modelData.checkbox ?? "" readonly property string cb: modelData.checkbox ?? ""
text: { text: {
switch (cb) { switch (cb) {
@ -463,6 +472,16 @@ ListView {
: theme.placeholderTextColor : theme.placeholderTextColor
font.pointSize: outline.baseFontSize + 1 font.pointSize: outline.baseFontSize + 1
font.bold: cb !== "" 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. // Item text; checked items are dimmed and struck through.

View File

@ -2,6 +2,7 @@
#include "orkclient.h" #include "orkclient.h"
#include <QDebug> #include <QDebug>
#include <QRegularExpression>
static const QStringList DONE_KEYWORDS = {"DONE", "CANCELLED", "CANCELED"}; 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()); 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() void DocumentModel::loadDocument()
{ {
beginResetModel(); beginResetModel();

View File

@ -70,6 +70,12 @@ public:
// `/ctl languages` (loaded once and cached). Case-insensitive. // `/ctl languages` (loaded once and cached). Case-insensitive.
Q_INVOKABLE bool isLanguageSupported(const QString &lang); 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: signals:
void documentChanged(); void documentChanged();