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.
This commit is contained in:
Levi Neely 2026-10-01 09:32:56 +02:00
parent 04425d9e9f
commit 70ec9509a4
3 changed files with 385 additions and 320 deletions

View File

@ -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
}
}

View File

@ -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 <QDebug>
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 /<doc>.
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<int>(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<int, QByteArray> DocumentModel::roleNames() const {
QHash<int, QByteArray> DocumentModel::roleNames() const
{
return {
{TitleRole, "title"},
{KeywordRole, "keyword"},
@ -253,5 +99,182 @@ QHash<int, QByteArray> 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<void(const QStringList &)> 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;
}

View File

@ -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:
// /<doc> -> the document (setDocument selects it)
// /<doc>/<id> -> a top-level section (a row under the invisible root)
// /<doc>/<id>/<cid> -> 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 <QAbstractItemModel>
#include <QAbstractListModel>
#include <QHash>
#include <QString>
#include <QVector>
#include <QSet>
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<int, QByteArray> 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<int> children; // indices into m_nodes
bool childrenLoaded = false;
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.
// 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<Node> m_nodes;
// All sections keyed by id
mutable QHash<QString, Section> m_sections;
// Top-level section ids in order
QStringList m_topLevelIds;
// Currently expanded section ids
QSet<QString> m_expanded;
// Visible row order (section ids)
QStringList m_visibleRows;
};