Keep keyword/priority editable when empty; add chooser menus

Once a keyword cycled to "none" its pill disappeared, leaving no target
to cycle or set it again; the priority cookie had the same dead end.

- The keyword pill and priority cookie now stay present when empty,
  rendered as a faint placeholder (+ / [#]) that brightens on hover, so
  a value can always be re-added. Left-click / wheel still cycle.
- Right-click opens a chooser menu. The keyword menu is populated from the
  document's keyword set via a new DocumentModel::keywordChoices() (reads
  /<doc>/keywords, drops the | separator) plus a "None" entry; the
  priority menu offers A/B/C and None. Selecting an item is one 9P write
  via setKeyword/setPriority.

Verified: keywordChoices returns TODO/DONE for a plain document and the
full declared sequence for a #+TODO: document; re-adding a keyword after
"none" and menu-selecting a specific state both work with the section id
stable. GUI builds clean.
This commit is contained in:
Levi Neely 2026-10-02 15:10:24 +02:00
parent 9de0c99f25
commit 741ca6d963
3 changed files with 110 additions and 22 deletions

View File

@ -411,27 +411,36 @@ ListView {
} }
} }
// TODO keyword pill. Click cycles the keyword forward // TODO keyword pill. Left-click / wheel cycles the keyword
// (TODO -> NEXT -> ... -> DONE -> none -> TODO); right-click or // (TODO -> NEXT -> ... -> DONE -> none -> TODO); right-click
// wheel cycles backward. Each change is one 9P write via the // opens a menu to pick a state directly. The pill stays present
// model, matching the "document is the control surface" model. // even when there is no keyword (shown as a faint placeholder),
// so a keyword can always be re-added. Each change is one 9P
// write via the model.
Rectangle { Rectangle {
visible: (del.model.keyword ?? "") !== "" id: kwPill
readonly property string kw: del.model.keyword ?? ""
readonly property bool hasKw: kw !== ""
radius: 3 radius: 3
color: kwMouse.containsMouse ? Qt.rgba( color: kwMouse.containsMouse ? Qt.rgba(
outline.keywordColor(del.model.keyword ?? "").r, outline.keywordColor(kw).r,
outline.keywordColor(del.model.keyword ?? "").g, outline.keywordColor(kw).g,
outline.keywordColor(del.model.keyword ?? "").b, 0.15) outline.keywordColor(kw).b, 0.15)
: "transparent" : "transparent"
border.color: outline.keywordColor(del.model.keyword ?? "") border.color: hasKw ? outline.keywordColor(kw)
: theme.placeholderTextColor
border.width: 1 border.width: 1
// Faint when empty unless hovered, so it stays discoverable
// without cluttering headlines that have no keyword.
opacity: hasKw ? 1.0 : (kwMouse.containsMouse ? 0.9 : 0.35)
implicitWidth: kwLabel.implicitWidth + 10 implicitWidth: kwLabel.implicitWidth + 10
implicitHeight: kwLabel.implicitHeight + 4 implicitHeight: kwLabel.implicitHeight + 4
Label { Label {
id: kwLabel id: kwLabel
anchors.centerIn: parent anchors.centerIn: parent
text: del.model.keyword ?? "" text: kwPill.hasKw ? kwPill.kw : "+"
color: outline.keywordColor(del.model.keyword ?? "") color: kwPill.hasKw ? outline.keywordColor(kwPill.kw)
: theme.placeholderTextColor
font.pointSize: outline.baseFontSize - 1 font.pointSize: outline.baseFontSize - 1
font.bold: true font.bold: true
} }
@ -443,38 +452,94 @@ ListView {
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (mouse) => { onClicked: (mouse) => {
docModel.cycleKeyword(del.index, mouse.button !== Qt.RightButton) if (mouse.button === Qt.RightButton)
kwMenu.openFor(del.index)
else
docModel.cycleKeyword(del.index, true)
} }
onWheel: (wheel) => { onWheel: (wheel) => {
docModel.cycleKeyword(del.index, wheel.angleDelta.y > 0) docModel.cycleKeyword(del.index, wheel.angleDelta.y > 0)
wheel.accepted = true wheel.accepted = true
} }
} }
// Keyword chooser: pick a state directly, or clear it.
// Populated from the document's keyword set on open.
Menu {
id: kwMenu
property int targetRow: -1
property var choices: []
function openFor(row) {
targetRow = row
choices = docModel.keywordChoices()
popup()
}
Repeater {
model: kwMenu.choices
delegate: MenuItem {
required property string modelData
text: modelData
onTriggered: docModel.setKeyword(kwMenu.targetRow, modelData)
}
}
MenuSeparator {}
MenuItem {
text: qsTr("None")
onTriggered: docModel.setKeyword(kwMenu.targetRow, "")
}
}
} }
// Priority cookie. Click cycles (none) -> A -> B -> C -> (none); // Priority cookie. Left-click / wheel cycles
// right-click / wheel-down steps back. One 9P write per change. // (none) -> A -> B -> C -> (none); right-click opens a menu to
// pick directly. Stays present (faint placeholder) when empty
// so a priority can be re-added. One 9P write per change.
Rectangle {
id: prioPill
readonly property string pri: del.model.priority ?? ""
readonly property bool hasPri: pri !== ""
radius: 3
color: "transparent"
opacity: hasPri ? 1.0 : (prioMouse.containsMouse ? 0.9 : 0.3)
implicitWidth: prioLabel.implicitWidth + 4
implicitHeight: prioLabel.implicitHeight + 2
Label { Label {
id: prioLabel id: prioLabel
visible: (del.model.priority ?? "") !== "" anchors.centerIn: parent
text: "[#" + (del.model.priority ?? "") + "]" text: prioPill.hasPri ? "[#" + prioPill.pri + "]" : "[#]"
color: outline.priorityColor(del.model.priority ?? "") color: prioPill.hasPri ? outline.priorityColor(prioPill.pri)
: theme.placeholderTextColor
font.pointSize: outline.baseFontSize font.pointSize: outline.baseFontSize
font.bold: true font.bold: true
}
MouseArea { MouseArea {
id: prioMouse
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (mouse) => { onClicked: (mouse) => {
docModel.cyclePriority(del.index, mouse.button !== Qt.RightButton) if (mouse.button === Qt.RightButton)
prioMenu.popup()
else
docModel.cyclePriority(del.index, true)
} }
onWheel: (wheel) => { onWheel: (wheel) => {
docModel.cyclePriority(del.index, wheel.angleDelta.y > 0) docModel.cyclePriority(del.index, wheel.angleDelta.y > 0)
wheel.accepted = true wheel.accepted = true
} }
} }
// Priority chooser: pick A/B/C directly, or clear it.
Menu {
id: prioMenu
MenuItem { text: "A"; onTriggered: docModel.setPriority(del.index, "A") }
MenuItem { text: "B"; onTriggered: docModel.setPriority(del.index, "B") }
MenuItem { text: "C"; onTriggered: docModel.setPriority(del.index, "C") }
MenuSeparator {}
MenuItem { text: qsTr("None"); onTriggered: docModel.setPriority(del.index, "") }
}
} }
// Title. Rendered as StyledText so inline org links in the // Title. Rendered as StyledText so inline org links in the

View File

@ -346,6 +346,24 @@ bool DocumentModel::cycleKeyword(int row, bool forward)
return setKeyword(row, states[next]); return setKeyword(row, states[next]);
} }
QStringList DocumentModel::keywordChoices()
{
QStringList choices;
if (!m_client)
return choices;
QString doc = m_document;
if (doc.startsWith('/'))
doc = doc.mid(1);
const QString kwLine = m_client->read(QStringLiteral("/%1/keywords").arg(doc));
for (const QString &tok : kwLine.split(QRegularExpression(QStringLiteral("\\s+")),
Qt::SkipEmptyParts)) {
if (tok == QLatin1String("|"))
continue;
choices.append(tok);
}
return choices;
}
bool DocumentModel::setPriority(int row, const QString &priority) bool DocumentModel::setPriority(int row, const QString &priority)
{ {
if (!m_client || row < 0 || row >= m_visibleRows.size()) if (!m_client || row < 0 || row >= m_visibleRows.size())

View File

@ -93,6 +93,11 @@ public:
// `forward` advances; otherwise steps back. Returns true on success. // `forward` advances; otherwise steps back. Returns true on success.
Q_INVOKABLE bool cycleKeyword(int row, bool forward); Q_INVOKABLE bool cycleKeyword(int row, bool forward);
// The current document's TODO keyword choices (its declared #+TODO:
// sequence, else the parser default TODO/DONE), in order and without the
// '|' separator. Used to populate a keyword chooser menu.
Q_INVOKABLE QStringList keywordChoices();
// Set a section's priority directly (A/B/C, or empty to remove). Writes // Set a section's priority directly (A/B/C, or empty to remove). Writes
// /<doc>/<id>/priority and refreshes the row. Returns true on success. // /<doc>/<id>/priority and refreshes the row. Returns true on success.
Q_INVOKABLE bool setPriority(int row, const QString &priority); Q_INVOKABLE bool setPriority(int row, const QString &priority);