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:
parent
04425d9e9f
commit
70ec9509a4
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue