diff --git a/ui/qml/OutlineView.qml b/ui/qml/OutlineView.qml index 0f4b87f..c52c4c1 100644 --- a/ui/qml/OutlineView.qml +++ b/ui/qml/OutlineView.qml @@ -411,27 +411,36 @@ ListView { } } - // 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. + // TODO keyword pill. Left-click / wheel cycles the keyword + // (TODO -> NEXT -> ... -> DONE -> none -> TODO); right-click + // opens a menu to pick a state directly. The pill stays present + // 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 { - visible: (del.model.keyword ?? "") !== "" + id: kwPill + readonly property string kw: del.model.keyword ?? "" + readonly property bool hasKw: kw !== "" radius: 3 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) + outline.keywordColor(kw).r, + outline.keywordColor(kw).g, + outline.keywordColor(kw).b, 0.15) : "transparent" - border.color: outline.keywordColor(del.model.keyword ?? "") + border.color: hasKw ? outline.keywordColor(kw) + : theme.placeholderTextColor 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 implicitHeight: kwLabel.implicitHeight + 4 Label { id: kwLabel anchors.centerIn: parent - text: del.model.keyword ?? "" - color: outline.keywordColor(del.model.keyword ?? "") + text: kwPill.hasKw ? kwPill.kw : "+" + color: kwPill.hasKw ? outline.keywordColor(kwPill.kw) + : theme.placeholderTextColor font.pointSize: outline.baseFontSize - 1 font.bold: true } @@ -443,38 +452,94 @@ ListView { cursorShape: Qt.PointingHandCursor acceptedButtons: Qt.LeftButton | Qt.RightButton 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) => { docModel.cycleKeyword(del.index, wheel.angleDelta.y > 0) 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); - // right-click / wheel-down steps back. One 9P write per change. - Label { - id: prioLabel - visible: (del.model.priority ?? "") !== "" - text: "[#" + (del.model.priority ?? "") + "]" - color: outline.priorityColor(del.model.priority ?? "") - font.pointSize: outline.baseFontSize - font.bold: true + // Priority cookie. Left-click / wheel cycles + // (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 { + id: prioLabel + anchors.centerIn: parent + text: prioPill.hasPri ? "[#" + prioPill.pri + "]" : "[#]" + color: prioPill.hasPri ? outline.priorityColor(prioPill.pri) + : theme.placeholderTextColor + font.pointSize: outline.baseFontSize + font.bold: true + } MouseArea { + id: prioMouse anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor acceptedButtons: Qt.LeftButton | Qt.RightButton 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) => { docModel.cyclePriority(del.index, wheel.angleDelta.y > 0) 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 diff --git a/ui/src/documentmodel.cpp b/ui/src/documentmodel.cpp index 5099808..bb93e80 100644 --- a/ui/src/documentmodel.cpp +++ b/ui/src/documentmodel.cpp @@ -346,6 +346,24 @@ bool DocumentModel::cycleKeyword(int row, bool forward) 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) { if (!m_client || row < 0 || row >= m_visibleRows.size()) diff --git a/ui/src/documentmodel.h b/ui/src/documentmodel.h index 3af38e6..372a769 100644 --- a/ui/src/documentmodel.h +++ b/ui/src/documentmodel.h @@ -93,6 +93,11 @@ public: // `forward` advances; otherwise steps back. Returns true on success. 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 // ///priority and refreshes the row. Returns true on success. Q_INVOKABLE bool setPriority(int row, const QString &priority);