Edit priority and tags by direct manipulation
Extend v0.4 direct manipulation to the priority cookie and tags, each a
single 9P write. Priority and tags are not part of the slug, so these
edits never change the section id.
Priority:
- DocumentModel::setPriority writes /<doc>/<id>/priority; cyclePriority
steps (none) -> A -> B -> C -> (none). The priority cookie gains a
MouseArea: left-click / wheel-up advance, right-click / wheel-down back.
Tags:
- DocumentModel::setTags writes /<doc>/<id>/tags from a free-form string
(space/comma/colon separated; the server normalizes and colon-joins).
- The tag row becomes editable: clicking a pill or the "+" affordance
opens an inline TextField prefilled with the current tags; Enter or
focus-loss commits, Escape cancels.
Server fix:
- set_section_priority passed None to rebuild_headline for an empty
priority, but rebuild_headline treats None as "keep the existing
value", so clearing a priority silently did nothing. Pass Some("")
explicitly, matching set_section_keyword. Found while testing the clear
path.
Verified: server tests (68 passed, +2 for priority cycle/clear and tag
set/clear keeping the id, plus the priority-clear fix). Live 9P run
cycled priority (none/A/B/C) and set/replaced tags on a headline with the
id stable throughout. GUI builds clean.
This commit is contained in:
parent
dc34f6467f
commit
d83324e1e4
|
|
@ -1218,10 +1218,12 @@ impl OrkState {
|
|||
let section = self.find_section(&doc_state.doc, id)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?;
|
||||
|
||||
// Always pass Some(&priority): rebuild_headline treats None as "keep
|
||||
// the existing priority", so an empty string (removal) must be passed
|
||||
// explicitly as Some("") — mirroring set_section_keyword. Using None
|
||||
// for empty here would silently leave the old priority in place.
|
||||
let priority = priority.trim().to_uppercase();
|
||||
let pri = if priority.is_empty() { None } else { Some(priority.as_str()) };
|
||||
|
||||
let new_headline = self.rebuild_headline(section, None, pri, None, None);
|
||||
let new_headline = self.rebuild_headline(section, None, Some(&priority), None, None);
|
||||
let new_content = self.replace_headline(&doc_state.content, section, &new_headline)?;
|
||||
|
||||
self.update_doc(doc_name, &doc_state.path, new_content)
|
||||
|
|
@ -2495,6 +2497,32 @@ mod tests {
|
|||
assert!(state.find_section(&state.get_doc("w").unwrap().doc, "first-task").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_priority_cycles_and_clears_keeping_id() {
|
||||
// Priority is not part of the slug, so setting/clearing it never
|
||||
// changes the section id. Empty clears the priority.
|
||||
let (_d, state) = state_with("#+TITLE: W\n* TODO Task one\n");
|
||||
state.set_section_priority("w", "task-one", "A").unwrap();
|
||||
assert_eq!(state.section_priority("w", "task-one").unwrap(), "A");
|
||||
state.set_section_priority("w", "task-one", "C").unwrap();
|
||||
assert_eq!(state.section_priority("w", "task-one").unwrap(), "C");
|
||||
state.set_section_priority("w", "task-one", "").unwrap();
|
||||
assert_eq!(state.section_priority("w", "task-one").unwrap_or_default(), "");
|
||||
assert!(state.find_section(&state.get_doc("w").unwrap().doc, "task-one").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_tags_accepts_free_form_and_clears() {
|
||||
// Tags may be space-separated on input and are stored colon-joined.
|
||||
// An empty write removes all tags. The id is unaffected.
|
||||
let (_d, state) = state_with("#+TITLE: W\n* TODO Task one\n");
|
||||
state.set_section_tags("w", "task-one", "work urgent home").unwrap();
|
||||
assert_eq!(state.section_tags("w", "task-one").unwrap(), "work:urgent:home");
|
||||
state.set_section_tags("w", "task-one", "").unwrap();
|
||||
assert_eq!(state.section_tags("w", "task-one").unwrap_or_default(), "");
|
||||
assert!(state.find_section(&state.get_doc("w").unwrap().doc, "task-one").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_section() {
|
||||
let (_d, state) = state_with("#+TITLE: W\n* One\n* Two\n* Three\n");
|
||||
|
|
|
|||
|
|
@ -129,6 +129,10 @@ ListView {
|
|||
|
||||
property bool drawerOpen: false
|
||||
|
||||
// Inline tag editing: when true, the tag pills are replaced by a text
|
||||
// field prefilled with the current tags.
|
||||
property bool editingTags: false
|
||||
|
||||
// Transient flash overlay when this row is revealed via a link.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
|
|
@ -441,13 +445,29 @@ ListView {
|
|||
}
|
||||
}
|
||||
|
||||
// Priority cookie
|
||||
// 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
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onClicked: (mouse) => {
|
||||
docModel.cyclePriority(del.index, mouse.button !== Qt.RightButton)
|
||||
}
|
||||
onWheel: (wheel) => {
|
||||
docModel.cyclePriority(del.index, wheel.angleDelta.y > 0)
|
||||
wheel.accepted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Title. Rendered as StyledText so inline org links in the
|
||||
|
|
@ -486,23 +506,86 @@ ListView {
|
|||
font.pointSize: outline.baseFontSize - 1
|
||||
}
|
||||
|
||||
// Tags
|
||||
Repeater {
|
||||
model: del.model.tags ?? []
|
||||
delegate: Rectangle {
|
||||
required property string modelData
|
||||
radius: 3
|
||||
color: theme.alternateBaseColor
|
||||
implicitWidth: tagLabel.implicitWidth + 8
|
||||
implicitHeight: tagLabel.implicitHeight + 3
|
||||
Label {
|
||||
id: tagLabel
|
||||
anchors.centerIn: parent
|
||||
text: parent.modelData
|
||||
color: theme.tagColor
|
||||
font.pointSize: outline.baseFontSize - 2
|
||||
// Tags. In display mode, each tag is a pill and clicking any
|
||||
// pill (or the "+" affordance) opens an inline editor. In edit
|
||||
// mode, a text field holds the space/colon/comma-separated tags;
|
||||
// Enter or focus-loss commits via one 9P write, Escape cancels.
|
||||
RowLayout {
|
||||
spacing: 4
|
||||
visible: !del.editingTags
|
||||
|
||||
Repeater {
|
||||
model: del.model.tags ?? []
|
||||
delegate: Rectangle {
|
||||
required property string modelData
|
||||
radius: 3
|
||||
color: tagMouse.containsMouse ? theme.highlightColor
|
||||
: theme.alternateBaseColor
|
||||
implicitWidth: tagLabel.implicitWidth + 8
|
||||
implicitHeight: tagLabel.implicitHeight + 3
|
||||
Label {
|
||||
id: tagLabel
|
||||
anchors.centerIn: parent
|
||||
text: parent.modelData
|
||||
color: theme.tagColor
|
||||
font.pointSize: outline.baseFontSize - 2
|
||||
}
|
||||
MouseArea {
|
||||
id: tagMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: del.editingTags = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "+" affordance to add/edit tags (always available).
|
||||
Label {
|
||||
text: "+"
|
||||
color: theme.placeholderTextColor
|
||||
font.pointSize: outline.baseFontSize - 1
|
||||
opacity: addTagMouse.containsMouse ? 1.0 : 0.5
|
||||
MouseArea {
|
||||
id: addTagMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: del.editingTags = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: tagsEditor
|
||||
visible: del.editingTags
|
||||
Layout.preferredWidth: outline.baseFontSize * 14
|
||||
placeholderText: qsTr("tag1 tag2 …")
|
||||
font.pointSize: outline.baseFontSize - 1
|
||||
selectByMouse: true
|
||||
|
||||
// Prefill with the current tags whenever edit mode opens.
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
text = (del.model.tags ?? []).join(" ")
|
||||
forceActiveFocus()
|
||||
selectAll()
|
||||
}
|
||||
}
|
||||
|
||||
function commit() {
|
||||
docModel.setTags(del.index, text.trim())
|
||||
del.editingTags = false
|
||||
}
|
||||
|
||||
onAccepted: commit()
|
||||
// Commit when focus is lost (click elsewhere), unless the
|
||||
// editor was already closed (e.g. via Escape).
|
||||
onActiveFocusChanged: {
|
||||
if (!activeFocus && del.editingTags)
|
||||
commit()
|
||||
}
|
||||
Keys.onEscapePressed: del.editingTags = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -346,6 +346,72 @@ bool DocumentModel::cycleKeyword(int row, bool forward)
|
|||
return setKeyword(row, states[next]);
|
||||
}
|
||||
|
||||
bool DocumentModel::setPriority(int row, const QString &priority)
|
||||
{
|
||||
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("/priority"), priority))
|
||||
return false;
|
||||
|
||||
sec.fieldsLoaded = false;
|
||||
loadFields(sec);
|
||||
|
||||
const QModelIndex idx = index(row);
|
||||
emit dataChanged(idx, idx, {PriorityRole});
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DocumentModel::cyclePriority(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);
|
||||
|
||||
// Cycle: (none) -> A -> B -> C -> (none).
|
||||
static const QStringList states = {QString(), QStringLiteral("A"),
|
||||
QStringLiteral("B"), QStringLiteral("C")};
|
||||
int cur = states.indexOf(sec.priority);
|
||||
if (cur < 0)
|
||||
cur = 0; // treat unknown as the empty state
|
||||
const int n = states.size();
|
||||
const int next = forward ? (cur + 1) % n : (cur - 1 + n) % n;
|
||||
return setPriority(row, states[next]);
|
||||
}
|
||||
|
||||
bool DocumentModel::setTags(int row, const QString &tags)
|
||||
{
|
||||
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("/tags"), tags))
|
||||
return false;
|
||||
|
||||
sec.fieldsLoaded = false;
|
||||
loadFields(sec);
|
||||
|
||||
const QModelIndex idx = index(row);
|
||||
emit dataChanged(idx, idx, {TagsRole});
|
||||
return true;
|
||||
}
|
||||
|
||||
int DocumentModel::revealSection(const QString §ionId, int bodyOffset)
|
||||
{
|
||||
if (sectionId.isEmpty() || !m_sections.contains(sectionId))
|
||||
|
|
|
|||
|
|
@ -93,6 +93,19 @@ public:
|
|||
// `forward` advances; otherwise steps back. Returns true on success.
|
||||
Q_INVOKABLE bool cycleKeyword(int row, bool forward);
|
||||
|
||||
// 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.
|
||||
Q_INVOKABLE bool setPriority(int row, const QString &priority);
|
||||
|
||||
// Cycle a section's priority through (none) -> A -> B -> C -> (none).
|
||||
// `forward` advances; otherwise steps back. Returns true on success.
|
||||
Q_INVOKABLE bool cyclePriority(int row, bool forward);
|
||||
|
||||
// Set a section's tags from a free-form string. Tags may be separated by
|
||||
// spaces, commas, or colons; the server normalizes them. An empty string
|
||||
// removes all tags. Writes /<doc>/<id>/tags and refreshes the row.
|
||||
Q_INVOKABLE bool setTags(int row, const QString &tags);
|
||||
|
||||
// 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
|
||||
// sectionRevealed(row, bodyOffset) so the view can scroll to and highlight
|
||||
|
|
|
|||
Loading…
Reference in New Issue