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)
|
let section = self.find_section(&doc_state.doc, id)
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?;
|
.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 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, Some(&priority), None, None);
|
||||||
|
|
||||||
let new_headline = self.rebuild_headline(section, None, pri, None, None);
|
|
||||||
let new_content = self.replace_headline(&doc_state.content, section, &new_headline)?;
|
let new_content = self.replace_headline(&doc_state.content, section, &new_headline)?;
|
||||||
|
|
||||||
self.update_doc(doc_name, &doc_state.path, new_content)
|
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());
|
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]
|
#[test]
|
||||||
fn test_delete_section() {
|
fn test_delete_section() {
|
||||||
let (_d, state) = state_with("#+TITLE: W\n* One\n* Two\n* Three\n");
|
let (_d, state) = state_with("#+TITLE: W\n* One\n* Two\n* Three\n");
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,10 @@ ListView {
|
||||||
|
|
||||||
property bool drawerOpen: false
|
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.
|
// Transient flash overlay when this row is revealed via a link.
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.fill: parent
|
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 {
|
Label {
|
||||||
|
id: prioLabel
|
||||||
visible: (del.model.priority ?? "") !== ""
|
visible: (del.model.priority ?? "") !== ""
|
||||||
text: "[#" + (del.model.priority ?? "") + "]"
|
text: "[#" + (del.model.priority ?? "") + "]"
|
||||||
color: outline.priorityColor(del.model.priority ?? "")
|
color: outline.priorityColor(del.model.priority ?? "")
|
||||||
font.pointSize: outline.baseFontSize
|
font.pointSize: outline.baseFontSize
|
||||||
font.bold: true
|
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
|
// Title. Rendered as StyledText so inline org links in the
|
||||||
|
|
@ -486,13 +506,21 @@ ListView {
|
||||||
font.pointSize: outline.baseFontSize - 1
|
font.pointSize: outline.baseFontSize - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tags
|
// 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 {
|
Repeater {
|
||||||
model: del.model.tags ?? []
|
model: del.model.tags ?? []
|
||||||
delegate: Rectangle {
|
delegate: Rectangle {
|
||||||
required property string modelData
|
required property string modelData
|
||||||
radius: 3
|
radius: 3
|
||||||
color: theme.alternateBaseColor
|
color: tagMouse.containsMouse ? theme.highlightColor
|
||||||
|
: theme.alternateBaseColor
|
||||||
implicitWidth: tagLabel.implicitWidth + 8
|
implicitWidth: tagLabel.implicitWidth + 8
|
||||||
implicitHeight: tagLabel.implicitHeight + 3
|
implicitHeight: tagLabel.implicitHeight + 3
|
||||||
Label {
|
Label {
|
||||||
|
|
@ -502,10 +530,65 @@ ListView {
|
||||||
color: theme.tagColor
|
color: theme.tagColor
|
||||||
font.pointSize: outline.baseFontSize - 2
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Properties drawer (collapsible)
|
// Properties drawer (collapsible)
|
||||||
RowLayout {
|
RowLayout {
|
||||||
visible: Object.keys(del.model.properties ?? {}).length > 0
|
visible: Object.keys(del.model.properties ?? {}).length > 0
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,72 @@ bool DocumentModel::cycleKeyword(int row, bool forward)
|
||||||
return setKeyword(row, states[next]);
|
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)
|
int DocumentModel::revealSection(const QString §ionId, int bodyOffset)
|
||||||
{
|
{
|
||||||
if (sectionId.isEmpty() || !m_sections.contains(sectionId))
|
if (sectionId.isEmpty() || !m_sections.contains(sectionId))
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,19 @@ 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);
|
||||||
|
|
||||||
|
// 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,
|
// 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
|
// and return its visible row index (-1 if the id is unknown). Emits
|
||||||
// sectionRevealed(row, bodyOffset) so the view can scroll to and highlight
|
// sectionRevealed(row, bodyOffset) so the view can scroll to and highlight
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue