From 70ec9509a4f3fedb4b1cb375b9796d29e9090e3b Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 1 Oct 2026 09:32:56 +0200 Subject: [PATCH] Replace TreeView with ListView for document outline TreeView (inherits TableView) is designed for file browsers with fixed row heights, not structured document editing. ListView gives us: - Full control over row heights (implicitHeight just works) - Simpler delegate without TreeViewDelegate quirks - Manual indentation via leftMargin based on level - Expansion state tracked in model with hasChildren/expanded roles - Click-to-expand triangles for sections with children DocumentModel changed from QAbstractItemModel (tree) to QAbstractListModel (flat list) with sections ordered depth-first. Tree structure preserved via level, parentId, childIds fields. Expansion state controls visibility. --- ui/qml/OutlineView.qml | 148 +++++++------ ui/src/documentmodel.cpp | 443 ++++++++++++++++++++------------------- ui/src/documentmodel.h | 114 +++++----- 3 files changed, 385 insertions(+), 320 deletions(-) diff --git a/ui/qml/OutlineView.qml b/ui/qml/OutlineView.qml index c073b30..ff36c92 100644 --- a/ui/qml/OutlineView.qml +++ b/ui/qml/OutlineView.qml @@ -2,19 +2,17 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -// A folding outline of a document's sections. Each row renders the section -// headline as distinct structural objects (keyword, priority, title, tags, -// timestamps) followed by the section body, always visible inline — the -// document is the control surface. -TreeView { - id: tree +// Document outline rendered as a flat ListView with indentation. +// Each section is a row; nesting is shown via left margin. +// This gives us full control over layout, selection, and editing. +ListView { + id: outline clip: true - - // Force TreeView to calculate row heights per-row instead of using a fixed height - rowHeightProvider: function(row) { return -1 } // -1 means "use delegate's implicitHeight" + spacing: 4 readonly property int rowPad: 6 readonly property int baseFontSize: theme.fontSize > 0 ? theme.fontSize : 12 + readonly property int indentWidth: 20 // Palette for TODO keywords by state. function keywordColor(kw) { @@ -40,90 +38,116 @@ TreeView { // Headline text scales down slightly with depth. function levelFontSize(level) { - return Math.max(tree.baseFontSize - 1, tree.baseFontSize + 5 - level) + return Math.max(outline.baseFontSize - 1, outline.baseFontSize + 5 - level) } - selectionModel: ItemSelectionModel {} - - delegate: TreeViewDelegate { + delegate: Rectangle { id: del - indentation: 18 + width: outline.width + implicitHeight: contentColumn.implicitHeight + outline.rowPad * 2 + color: ListView.isCurrentItem ? Qt.rgba(theme.highlightColor.r, theme.highlightColor.g, theme.highlightColor.b, 0.2) : "transparent" + radius: 4 + + required property int index + required property var model readonly property string sectionBody: (model.body ?? "").trim() - readonly property int bodyIndent: 12 + readonly property int sectionLevel: model.level ?? 1 + readonly property int indent: (sectionLevel - 1) * outline.indentWidth - implicitWidth: tree.width - implicitHeight: contentColumn.implicitHeight - - background: Rectangle { - color: del.current ? theme.highlightColor : "transparent" - opacity: del.current ? 0.2 : 1.0 - radius: 4 + MouseArea { + anchors.fill: parent + onClicked: outline.currentIndex = del.index } - contentItem: ColumnLayout { + ColumnLayout { id: contentColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: del.indent + 8 + anchors.rightMargin: 8 + anchors.topMargin: outline.rowPad spacing: 4 RowLayout { id: headlineRow Layout.fillWidth: true - Layout.topMargin: tree.rowPad spacing: 8 - // TODO keyword pill. + // Expand/collapse indicator for sections with children + Label { + visible: (del.model.hasChildren ?? false) + text: (del.model.expanded ?? true) ? "▼" : "▶" + color: theme.placeholderTextColor + font.pointSize: outline.baseFontSize - 2 + + MouseArea { + anchors.fill: parent + onClicked: { + // Toggle expansion via model + if (del.model.expanded !== undefined) { + docModel.toggleExpanded(del.index) + } + } + } + } + + // TODO keyword pill Rectangle { - visible: (model.keyword ?? "") !== "" + visible: (del.model.keyword ?? "") !== "" radius: 3 color: "transparent" - border.color: tree.keywordColor(model.keyword ?? "") + border.color: outline.keywordColor(del.model.keyword ?? "") border.width: 1 implicitWidth: kwLabel.implicitWidth + 10 implicitHeight: kwLabel.implicitHeight + 4 Label { id: kwLabel anchors.centerIn: parent - text: model.keyword ?? "" - color: tree.keywordColor(model.keyword ?? "") - font.pointSize: tree.baseFontSize - 1 + text: del.model.keyword ?? "" + color: outline.keywordColor(del.model.keyword ?? "") + font.pointSize: outline.baseFontSize - 1 font.bold: true } } - // Priority cookie. + // Priority cookie Label { - visible: (model.priority ?? "") !== "" - text: "[#" + (model.priority ?? "") + "]" - color: tree.priorityColor(model.priority ?? "") - font.pointSize: tree.baseFontSize + visible: (del.model.priority ?? "") !== "" + text: "[#" + (del.model.priority ?? "") + "]" + color: outline.priorityColor(del.model.priority ?? "") + font.pointSize: outline.baseFontSize font.bold: true } - // Title. + // Title Label { - text: (model.title ?? "") - color: (model.isDone ?? false) ? theme.placeholderTextColor : theme.textColor - font.pointSize: tree.levelFontSize(model.level ?? 1) - font.strikeout: (model.isDone ?? false) + text: del.model.title ?? "" + color: (del.model.isDone ?? false) ? theme.placeholderTextColor : theme.textColor + font.pointSize: outline.levelFontSize(del.sectionLevel) + font.strikeout: del.model.isDone ?? false elide: Text.ElideRight Layout.fillWidth: true } - // Scheduled / deadline stamps. + // Scheduled timestamp Label { - visible: (model.scheduled ?? "") !== "" - text: "⏱ " + (model.scheduled ?? "") + visible: (del.model.scheduled ?? "") !== "" + text: "⏱ " + (del.model.scheduled ?? "") color: theme.timestampColor - font.pointSize: tree.baseFontSize - 1 - } - Label { - visible: (model.deadline ?? "") !== "" - text: "⚑ " + (model.deadline ?? "") - color: theme.deadlineColor - font.pointSize: tree.baseFontSize - 1 + font.pointSize: outline.baseFontSize - 1 } - // Tags. + // Deadline timestamp + Label { + visible: (del.model.deadline ?? "") !== "" + text: "⚑ " + (del.model.deadline ?? "") + color: theme.deadlineColor + font.pointSize: outline.baseFontSize - 1 + } + + // Tags Repeater { model: del.model.tags ?? [] delegate: Rectangle { @@ -137,20 +161,18 @@ TreeView { anchors.centerIn: parent text: parent.modelData color: theme.tagColor - font.pointSize: tree.baseFontSize - 2 + font.pointSize: outline.baseFontSize - 2 } } } } - // Body row with left rule. + // Body text with left rule RowLayout { visible: del.sectionBody !== "" Layout.fillWidth: true - Layout.bottomMargin: tree.rowPad spacing: 8 - // Left rule marking the body region. Rectangle { width: 2 Layout.fillHeight: true @@ -158,14 +180,11 @@ TreeView { color: theme.midColor } - // Section body text. - Text { - id: bodyLabel + Label { Layout.fillWidth: true - Layout.leftMargin: del.bodyIndent - 10 text: del.sectionBody color: theme.placeholderTextColor - font.pointSize: tree.baseFontSize + font.pointSize: outline.baseFontSize lineHeight: 1.15 wrapMode: Text.WordWrap textFormat: Text.PlainText @@ -173,4 +192,13 @@ TreeView { } } } + + // Empty state + Label { + anchors.centerIn: parent + visible: outline.count === 0 + text: qsTr("No sections") + color: theme.placeholderTextColor + font.pointSize: outline.baseFontSize + } } diff --git a/ui/src/documentmodel.cpp b/ui/src/documentmodel.cpp index 8add88f..c276d38 100644 --- a/ui/src/documentmodel.cpp +++ b/ui/src/documentmodel.cpp @@ -1,245 +1,91 @@ #include "documentmodel.h" - #include "orkclient.h" -namespace { -// A trailing newline is emitted by every scalar field file; strip it. -QString trimField(const QString &s) { - QString t = s; - while (t.endsWith('\n') || t.endsWith('\r')) t.chop(1); - return t; -} -} // namespace +#include -DocumentModel::DocumentModel(QObject *parent) : QAbstractItemModel(parent) { - // Node 0 is the invisible root. - Node root; - m_nodes.append(root); +static const QStringList DONE_KEYWORDS = {"DONE", "CANCELLED", "CANCELED"}; +static const QStringList SKIP_ENTRIES = {"raw", "body", "meta", "new"}; + +DocumentModel::DocumentModel(QObject *parent) + : QAbstractListModel(parent) +{ } -void DocumentModel::setClient(OrkClient *client) { +void DocumentModel::setClient(OrkClient *client) +{ m_client = client; - reload(); } -void DocumentModel::setDocument(const QString &doc) { - if (doc == m_document) return; - m_document = doc; - emit documentChanged(); - reload(); -} - -void DocumentModel::reload() { - beginResetModel(); - m_nodes.clear(); - Node root; - m_nodes.append(root); - - if (m_client && !m_document.isEmpty()) { - // Top-level section directories under /. - const QStringList ids = m_client->listDirs("/" + m_document); - for (const QString &id : ids) { - const QString path = "/" + m_document + "/" + id; - const int idx = makeNode(id, path, 0); - m_nodes[0].children.append(idx); - } - m_nodes[0].childrenLoaded = true; - } - endResetModel(); -} - -int DocumentModel::makeNode(const QString &id, const QString &path, int parent) { - Node n; - n.id = id; - n.path = path; - n.parent = parent; - m_nodes.append(n); - return m_nodes.size() - 1; -} - -void DocumentModel::loadFields(int idx) const { - Node &n = m_nodes[idx]; - if (n.fieldsLoaded || !m_client || n.path.isEmpty()) return; - - n.title = trimField(m_client->read(n.path + "/title")); - n.keyword = trimField(m_client->read(n.path + "/keyword")); - n.priority = trimField(m_client->read(n.path + "/priority")); - const QString tags = trimField(m_client->read(n.path + "/tags")); - n.tags = tags.isEmpty() ? QStringList() - : tags.split(':', Qt::SkipEmptyParts); - n.level = trimField(m_client->read(n.path + "/level")).toInt(); - n.scheduled = trimField(m_client->read(n.path + "/scheduled")); - n.deadline = trimField(m_client->read(n.path + "/deadline")); - n.fieldsLoaded = true; -} - -void DocumentModel::loadBody(int idx) const { - Node &n = m_nodes[idx]; - if (n.bodyLoaded || !m_client || n.path.isEmpty()) return; - - // The server's /body is the section content beneath the headline, - // excluding child subtrees. It still contains the planning line - // (SCHEDULED/DEADLINE/CLOSED) and the :PROPERTIES: drawer, which the GUI - // already surfaces as structural objects. Strip those leading lines so the - // body view shows just the prose/content. - const QString raw = m_client->read(n.path + "/body"); - QStringList lines = raw.split('\n'); - - int start = 0; - // Skip a leading planning line (SCHEDULED:/DEADLINE:/CLOSED: keywords). - while (start < lines.size()) { - const QString t = lines[start].trimmed(); - if (t.startsWith("SCHEDULED:") || t.startsWith("DEADLINE:") || - t.startsWith("CLOSED:")) { - ++start; - } else { - break; - } - } - // Skip a :PROPERTIES: ... :END: drawer if present next. - if (start < lines.size() && - lines[start].trimmed().compare(":PROPERTIES:", Qt::CaseInsensitive) == 0) { - int end = start + 1; - while (end < lines.size() && - lines[end].trimmed().compare(":END:", Qt::CaseInsensitive) != 0) { - ++end; - } - if (end < lines.size()) start = end + 1; // consume through :END: - } - - QString body = lines.mid(start).join('\n'); - // Trim leading and trailing blank lines for display. - while (body.startsWith('\n') || body.startsWith('\r')) body.remove(0, 1); - while (body.endsWith('\n') || body.endsWith('\r')) body.chop(1); - n.body = body; - n.bodyLoaded = true; -} - -int DocumentModel::indexOf(const QModelIndex &index) const { - if (!index.isValid()) return 0; // root - return static_cast(index.internalId()); -} - -const DocumentModel::Node *DocumentModel::nodeFor(const QModelIndex &index) const { - const int i = indexOf(index); - if (i < 0 || i >= m_nodes.size()) return nullptr; - return &m_nodes[i]; -} - -QModelIndex DocumentModel::index(int row, int column, const QModelIndex &parent) const { - if (column != 0) return {}; - const int pidx = indexOf(parent); - if (pidx < 0 || pidx >= m_nodes.size()) return {}; - const Node &p = m_nodes[pidx]; - if (row < 0 || row >= p.children.size()) return {}; - return createIndex(row, column, quintptr(p.children[row])); -} - -QModelIndex DocumentModel::parent(const QModelIndex &child) const { - const int cidx = indexOf(child); - if (cidx <= 0 || cidx >= m_nodes.size()) return {}; // root or invalid - const Node &c = m_nodes[cidx]; - const int pidx = c.parent; - if (pidx <= 0) return {}; // parent is the invisible root - const Node &p = m_nodes[pidx]; - const int gp = p.parent; - if (gp < 0 || gp >= m_nodes.size()) return {}; - const int rowInGp = m_nodes[gp].children.indexOf(pidx); - if (rowInGp < 0) return {}; - return createIndex(rowInGp, 0, quintptr(pidx)); -} - -int DocumentModel::rowCount(const QModelIndex &parent) const { - const int pidx = indexOf(parent); - if (pidx < 0 || pidx >= m_nodes.size()) return 0; - return m_nodes[pidx].children.size(); -} - -int DocumentModel::columnCount(const QModelIndex &) const { return 1; } - -bool DocumentModel::hasChildren(const QModelIndex &parent) const { - const int pidx = indexOf(parent); - if (pidx < 0 || pidx >= m_nodes.size()) return false; - const Node &n = m_nodes[pidx]; - if (n.childrenLoaded) return !n.children.isEmpty(); - // Unknown until fetched — assume it may have children so the view shows a - // disclosure control and triggers canFetchMore/fetchMore. - return true; -} - -bool DocumentModel::canFetchMore(const QModelIndex &parent) const { - const int pidx = indexOf(parent); - if (pidx < 0 || pidx >= m_nodes.size()) return false; - return !m_nodes[pidx].childrenLoaded; -} - -void DocumentModel::fetchMore(const QModelIndex &parent) { - const int pidx = indexOf(parent); - if (pidx < 0 || pidx >= m_nodes.size()) return; - if (m_nodes[pidx].childrenLoaded) return; - - // Determine how many children we're about to add. - const QStringList ids = - (m_client && !m_nodes[pidx].path.isEmpty()) - ? m_client->listDirs(m_nodes[pidx].path) - : QStringList(); - - if (ids.isEmpty()) { - m_nodes[pidx].childrenLoaded = true; +void DocumentModel::setDocument(const QString &doc) +{ + if (doc == m_document) return; - } - beginInsertRows(parent, 0, ids.size() - 1); - for (const QString &cid : ids) { - const QString cpath = m_nodes[pidx].path + "/" + cid; - const int cidx = makeNode(cid, cpath, pidx); - m_nodes[pidx].children.append(cidx); - } - m_nodes[pidx].childrenLoaded = true; - endInsertRows(); + m_document = doc; + loadDocument(); + emit documentChanged(); } -QVariant DocumentModel::data(const QModelIndex &index, int role) const { - const Node *n = nodeFor(index); - if (!n || n->path.isEmpty()) return {}; - const int idx = indexOf(index); - loadFields(idx); - const Node &c = m_nodes[idx]; +int DocumentModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) + return 0; + return m_visibleRows.size(); +} + +QVariant DocumentModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_visibleRows.size()) + return {}; + + const QString &id = m_visibleRows[index.row()]; + if (!m_sections.contains(id)) + return {}; + + Section &sec = m_sections[id]; + + // Lazy load fields + if (!sec.fieldsLoaded) + loadFields(sec); switch (role) { case TitleRole: - case Qt::DisplayRole: - return c.title; + return sec.title; case KeywordRole: - return c.keyword; + return sec.keyword; case PriorityRole: - return c.priority; + return sec.priority; case TagsRole: - return c.tags; + return sec.tags; case LevelRole: - return c.level; + return sec.level; case ScheduledRole: - return c.scheduled; + return sec.scheduled; case DeadlineRole: - return c.deadline; + return sec.deadline; case SectionIdRole: - return c.id; + return sec.id; case PathRole: - return c.path; + return sec.path; case IsTodoRole: - return !c.keyword.isEmpty(); + return !sec.keyword.isEmpty(); case IsDoneRole: - return c.keyword == QLatin1String("DONE") || - c.keyword == QLatin1String("CANCELLED"); + return DONE_KEYWORDS.contains(sec.keyword); case BodyRole: - loadBody(idx); - return m_nodes[idx].body; + if (!sec.bodyLoaded) + loadBody(sec); + return sec.body; + case HasChildrenRole: + return !sec.childIds.isEmpty(); + case ExpandedRole: + return m_expanded.contains(id); default: return {}; } } -QHash DocumentModel::roleNames() const { +QHash DocumentModel::roleNames() const +{ return { {TitleRole, "title"}, {KeywordRole, "keyword"}, @@ -253,5 +99,182 @@ QHash DocumentModel::roleNames() const { {IsTodoRole, "isTodo"}, {IsDoneRole, "isDone"}, {BodyRole, "body"}, + {HasChildrenRole, "hasChildren"}, + {ExpandedRole, "expanded"}, }; } + +void DocumentModel::toggleExpanded(int row) +{ + if (row < 0 || row >= m_visibleRows.size()) + return; + + const QString &id = m_visibleRows[row]; + if (!m_sections.contains(id)) + return; + + const Section &sec = m_sections[id]; + if (sec.childIds.isEmpty()) + return; // No children to expand/collapse + + if (m_expanded.contains(id)) + m_expanded.remove(id); + else + m_expanded.insert(id); + + // Rebuild visible rows and notify + rebuildVisibleRows(); +} + +void DocumentModel::reload() +{ + loadDocument(); +} + +void DocumentModel::loadDocument() +{ + beginResetModel(); + m_sections.clear(); + m_topLevelIds.clear(); + m_expanded.clear(); + m_visibleRows.clear(); + + if (!m_client || m_document.isEmpty()) { + endResetModel(); + return; + } + + // Load top-level sections + loadSections(m_document, QString(), 1); + + // Start with all sections expanded + for (const QString &id : m_sections.keys()) + m_expanded.insert(id); + + rebuildVisibleRows(); + endResetModel(); +} + +void DocumentModel::loadSections(const QString &parentPath, const QString &parentId, int level) +{ + QStringList entries = m_client->list(parentPath); + QStringList sectionIds; + + for (const QString &entry : entries) { + // Skip non-section entries + if (SKIP_ENTRIES.contains(entry)) + continue; + + Section sec; + sec.id = entry; + sec.path = parentPath + "/" + entry; + sec.level = level; + sec.parentId = parentId; + + m_sections[entry] = sec; + sectionIds.append(entry); + + // Recursively load children + loadSections(sec.path, entry, level + 1); + + // Update child list + m_sections[entry].childIds = childIdsFor(sec.path, entry); + } + + if (parentId.isEmpty()) { + // Top-level sections + m_topLevelIds = sectionIds; + } else if (m_sections.contains(parentId)) { + m_sections[parentId].childIds = sectionIds; + } +} + +// Helper to get child ids for a section +QStringList DocumentModel::childIdsFor(const QString &path, const QString &) const +{ + QStringList entries = m_client->list(path); + QStringList childIds; + for (const QString &entry : entries) { + if (!SKIP_ENTRIES.contains(entry) && m_sections.contains(entry)) + childIds.append(entry); + } + return childIds; +} + +void DocumentModel::loadFields(Section &sec) const +{ + if (!m_client || sec.fieldsLoaded) + return; + + sec.title = m_client->read(sec.path + "/title").trimmed(); + sec.keyword = m_client->read(sec.path + "/keyword").trimmed(); + sec.priority = m_client->read(sec.path + "/priority").trimmed(); + sec.scheduled = m_client->read(sec.path + "/scheduled").trimmed(); + sec.deadline = m_client->read(sec.path + "/deadline").trimmed(); + + QString tagsStr = m_client->read(sec.path + "/tags").trimmed(); + if (!tagsStr.isEmpty()) + sec.tags = tagsStr.split(':'); + + // Level is already set from tree structure + sec.fieldsLoaded = true; +} + +void DocumentModel::loadBody(Section &sec) const +{ + if (!m_client || sec.bodyLoaded) + return; + + sec.body = m_client->read(sec.path + "/body"); + sec.bodyLoaded = true; +} + +void DocumentModel::rebuildVisibleRows() +{ + QStringList oldRows = m_visibleRows; + m_visibleRows.clear(); + + // DFS traversal respecting expansion state + std::function addSections = [&](const QStringList &ids) { + for (const QString &id : ids) { + if (!m_sections.contains(id)) + continue; + + m_visibleRows.append(id); + + const Section &sec = m_sections[id]; + if (m_expanded.contains(id) && !sec.childIds.isEmpty()) { + addSections(sec.childIds); + } + } + }; + + addSections(m_topLevelIds); + + // Emit change signals + if (m_visibleRows != oldRows) { + emit dataChanged(index(0), index(rowCount() - 1)); + emit layoutChanged(); + } +} + +bool DocumentModel::isSectionVisible(const QString &id) const +{ + if (!m_sections.contains(id)) + return false; + + const Section &sec = m_sections[id]; + if (sec.parentId.isEmpty()) + return true; // Top-level always visible + + // Check if all ancestors are expanded + QString ancestorId = sec.parentId; + while (!ancestorId.isEmpty()) { + if (!m_expanded.contains(ancestorId)) + return false; + if (!m_sections.contains(ancestorId)) + return false; + ancestorId = m_sections[ancestorId].parentId; + } + return true; +} diff --git a/ui/src/documentmodel.h b/ui/src/documentmodel.h index c1d306b..b36dcec 100644 --- a/ui/src/documentmodel.h +++ b/ui/src/documentmodel.h @@ -1,27 +1,23 @@ -// DocumentModel — a lazy tree model over one document's section hierarchy. +// DocumentModel — a flat list model over one document's sections. // -// The model maps the ork-server namespace directly: -// / -> the document (setDocument selects it) -// // -> a top-level section (a row under the invisible root) -// /// -> a child section (a nested row) +// Maps the ork-server namespace to a flat list where each section is a row. +// Tree structure is represented by the level field (1 = top-level, 2 = child, etc). +// Children appear immediately after their parent in the list. // -// Each section's display fields (title, keyword, priority, tags, level, -// scheduled, deadline) are read once from the corresponding leaf files when -// the node is first materialised. Children are fetched lazily on expansion so -// large outlines do not force a full walk up front. +// Expansion state is tracked per-section. Collapsed sections hide their children. // -// This is a read-only view for v0.3; field roles are exposed so a later -// editing milestone can add setData without reshaping the model. +// This is a read-only view for v0.3; editing will add setData. #pragma once -#include +#include #include #include #include +#include class OrkClient; -class DocumentModel : public QAbstractItemModel { +class DocumentModel : public QAbstractListModel { Q_OBJECT Q_PROPERTY(QString document READ document WRITE setDocument NOTIFY documentChanged) @@ -30,15 +26,17 @@ public: TitleRole = Qt::UserRole + 1, KeywordRole, PriorityRole, - TagsRole, // QStringList + TagsRole, // QStringList LevelRole, ScheduledRole, DeadlineRole, SectionIdRole, - PathRole, // full namespace path of the section directory - IsTodoRole, // keyword is non-empty - IsDoneRole, // keyword is a "done" state (DONE/CANCELLED) - BodyRole, // section content beneath the headline (lazy) + PathRole, // full namespace path of the section directory + IsTodoRole, // keyword is non-empty + IsDoneRole, // keyword is a "done" state (DONE/CANCELLED) + BodyRole, // section content beneath the headline (lazy) + HasChildrenRole, // true if section has children + ExpandedRole, // true if section is expanded (children visible) }; Q_ENUM(Roles) @@ -49,59 +47,75 @@ public: QString document() const { return m_document; } void setDocument(const QString &doc); - // QAbstractItemModel. - QModelIndex index(int row, int column, const QModelIndex &parent) const override; - QModelIndex parent(const QModelIndex &child) const override; - int rowCount(const QModelIndex &parent) const override; - int columnCount(const QModelIndex &parent) const override; + // QAbstractListModel + int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role) const override; QHash roleNames() const override; - bool hasChildren(const QModelIndex &parent) const override; - bool canFetchMore(const QModelIndex &parent) const override; - void fetchMore(const QModelIndex &parent) override; - // Reload from the server (re-reads doc sections). + // Toggle expansion for a section by row index + Q_INVOKABLE void toggleExpanded(int row); + + // Reload from the server Q_INVOKABLE void reload(); signals: void documentChanged(); private: - struct Node { - QString id; // section UUID/custom-id (empty for root) - QString path; // "/doc/id" (empty for root) - int parent = -1; // index into m_nodes, -1 for root - QVector children; // indices into m_nodes - bool childrenLoaded = false; - - // Cached display fields. + struct Section { + QString id; // section UUID/custom-id + QString path; // "/doc/id" + int level = 1; // nesting level (1 = top-level) + QString parentId; // id of parent section (empty for top-level) + QStringList childIds; // ids of direct children + + // Cached display fields QString title; QString keyword; QString priority; QStringList tags; - int level = 0; QString scheduled; QString deadline; bool fieldsLoaded = false; - - // Section body is read separately, only when first requested, since it - // can be large and is not needed to render the headline row. + + // Section body (lazy) QString body; bool bodyLoaded = false; }; - // Create a section node, register it, and return its index. Does not load - // fields or children. - int makeNode(const QString &id, const QString &path, int parent); - void loadFields(int idx) const; // populates cached headline fields - void loadBody(int idx) const; // populates cached body text - const Node *nodeFor(const QModelIndex &index) const; - int indexOf(const QModelIndex &index) const; // node index, -1 == root + // Load all sections for the current document + void loadDocument(); + + // Recursively load sections under a parent path + void loadSections(const QString &parentPath, const QString &parentId, int level); + + // Load display fields for a section + void loadFields(Section &sec) const; + + // Load body for a section + void loadBody(Section &sec) const; + + // Helper to get child ids for a section + QStringList childIdsFor(const QString &path, const QString &parentId) const; + + // Rebuild the visible row list based on expansion state + void rebuildVisibleRows(); + + // Check if a section is currently visible (all ancestors expanded) + bool isSectionVisible(const QString &id) const; OrkClient *m_client = nullptr; QString m_document; - - // Node 0 is the invisible root. Sections start at index 1. - // mutable because field loading is lazy inside const data(). - mutable QVector m_nodes; + + // All sections keyed by id + mutable QHash m_sections; + + // Top-level section ids in order + QStringList m_topLevelIds; + + // Currently expanded section ids + QSet m_expanded; + + // Visible row order (section ids) + QStringList m_visibleRows; };