690 lines
20 KiB
C++
690 lines
20 KiB
C++
#include "documentmodel.h"
|
|
#include "orkclient.h"
|
|
|
|
#include <QDebug>
|
|
#include <QDesktopServices>
|
|
#include <QRegularExpression>
|
|
#include <QUrl>
|
|
|
|
static const QStringList DONE_KEYWORDS = {"DONE", "CANCELLED", "CANCELED"};
|
|
|
|
DocumentModel::DocumentModel(QObject *parent)
|
|
: QAbstractListModel(parent)
|
|
{
|
|
}
|
|
|
|
void DocumentModel::setClient(OrkClient *client)
|
|
{
|
|
m_client = client;
|
|
}
|
|
|
|
void DocumentModel::setDocument(const QString &doc)
|
|
{
|
|
if (doc == m_document)
|
|
return;
|
|
|
|
m_document = doc;
|
|
loadDocument();
|
|
emit documentChanged();
|
|
}
|
|
|
|
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:
|
|
return sec.title;
|
|
case KeywordRole:
|
|
return sec.keyword;
|
|
case PriorityRole:
|
|
return sec.priority;
|
|
case TagsRole:
|
|
return sec.tags;
|
|
case LevelRole:
|
|
return sec.level;
|
|
case ScheduledRole:
|
|
return sec.scheduled;
|
|
case DeadlineRole:
|
|
return sec.deadline;
|
|
case SectionIdRole:
|
|
return sec.id;
|
|
case PathRole:
|
|
return sec.path;
|
|
case IsTodoRole:
|
|
return !sec.keyword.isEmpty();
|
|
case IsDoneRole:
|
|
return DONE_KEYWORDS.contains(sec.keyword);
|
|
case BodyRole:
|
|
if (!sec.bodyLoaded)
|
|
loadBody(sec);
|
|
return sec.body;
|
|
case PropertiesRole:
|
|
if (!sec.propertiesLoaded)
|
|
loadProperties(sec);
|
|
return sec.properties;
|
|
case HasChildrenRole:
|
|
return !sec.childIds.isEmpty();
|
|
case ExpandedRole:
|
|
return m_expanded.contains(id);
|
|
default:
|
|
return {};
|
|
}
|
|
}
|
|
|
|
QHash<int, QByteArray> DocumentModel::roleNames() const
|
|
{
|
|
return {
|
|
{TitleRole, "title"},
|
|
{KeywordRole, "keyword"},
|
|
{PriorityRole, "priority"},
|
|
{TagsRole, "tags"},
|
|
{LevelRole, "level"},
|
|
{ScheduledRole, "scheduled"},
|
|
{DeadlineRole, "deadline"},
|
|
{SectionIdRole, "sectionId"},
|
|
{PathRole, "path"},
|
|
{IsTodoRole, "isTodo"},
|
|
{IsDoneRole, "isDone"},
|
|
{BodyRole, "body"},
|
|
{PropertiesRole, "properties"},
|
|
{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();
|
|
}
|
|
|
|
QString DocumentModel::executeBlock(int row, int blockIndex)
|
|
{
|
|
if (!m_client || m_document.isEmpty())
|
|
return QStringLiteral("error: not connected");
|
|
if (row < 0 || row >= m_visibleRows.size())
|
|
return QStringLiteral("error: invalid row");
|
|
|
|
const QString &id = m_visibleRows[row];
|
|
if (!m_sections.contains(id))
|
|
return QStringLiteral("error: unknown section");
|
|
|
|
// Document name without a leading slash for the ctl verb.
|
|
QString doc = m_document;
|
|
if (doc.startsWith('/'))
|
|
doc = doc.mid(1);
|
|
|
|
const QString cmd = QStringLiteral("execute %1 %2 %3")
|
|
.arg(doc, id)
|
|
.arg(blockIndex);
|
|
const QString result = m_client->rdwr(QStringLiteral("/ctl"), cmd);
|
|
|
|
// Refresh the section body so the inserted #+RESULTS: block is rendered.
|
|
Section &sec = m_sections[id];
|
|
sec.bodyLoaded = false;
|
|
loadBody(sec);
|
|
|
|
const QModelIndex idx = index(row);
|
|
emit dataChanged(idx, idx, {BodyRole});
|
|
|
|
return result;
|
|
}
|
|
|
|
bool DocumentModel::isLanguageSupported(const QString &lang)
|
|
{
|
|
if (lang.isEmpty())
|
|
return false;
|
|
|
|
if (!m_languagesLoaded) {
|
|
m_supportedLanguages.clear();
|
|
if (m_client) {
|
|
const QString out = m_client->rdwr(QStringLiteral("/ctl"),
|
|
QStringLiteral("languages"));
|
|
for (const QString &line : out.split('\n', Qt::SkipEmptyParts))
|
|
m_supportedLanguages.append(line.trimmed().toLower());
|
|
}
|
|
m_languagesLoaded = true;
|
|
}
|
|
|
|
return m_supportedLanguages.contains(lang.trimmed().toLower());
|
|
}
|
|
|
|
bool DocumentModel::rewriteCheckbox(
|
|
int row, int checkboxOrdinal,
|
|
const std::function<QString(const QString &)> &nextState)
|
|
{
|
|
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.bodyLoaded)
|
|
loadBody(sec);
|
|
|
|
// Match a list item's checkbox cookie: leading indent, a bullet (- / +) or
|
|
// ordered marker (N. / N)), then a [ ]/[x]/[X]/[-] cookie.
|
|
static const QRegularExpression cookieRe(
|
|
QStringLiteral("^(\\s*(?:[-+]|\\d+[.)])\\s+\\[)([ xX-])(\\])"));
|
|
|
|
const QStringList lines = sec.body.split('\n');
|
|
QStringList out;
|
|
out.reserve(lines.size());
|
|
|
|
int seen = -1;
|
|
bool changed = false;
|
|
for (const QString &line : lines) {
|
|
QRegularExpressionMatch m = cookieRe.match(line);
|
|
if (m.hasMatch()) {
|
|
++seen;
|
|
if (seen == checkboxOrdinal) {
|
|
const QString next = nextState(m.captured(2));
|
|
QString updated = line;
|
|
updated.replace(m.capturedStart(2), m.capturedLength(2), next);
|
|
out.append(updated);
|
|
changed = true;
|
|
continue;
|
|
}
|
|
}
|
|
out.append(line);
|
|
}
|
|
|
|
if (!changed)
|
|
return false;
|
|
|
|
const QString newBody = out.join('\n');
|
|
if (!m_client->write(sec.path + QStringLiteral("/body"), newBody))
|
|
return false;
|
|
|
|
// Refresh from the server so the parsed state matches what was written.
|
|
sec.bodyLoaded = false;
|
|
loadBody(sec);
|
|
|
|
const QModelIndex idx = index(row);
|
|
emit dataChanged(idx, idx, {BodyRole});
|
|
return true;
|
|
}
|
|
|
|
bool DocumentModel::toggleCheckbox(int row, int checkboxOrdinal)
|
|
{
|
|
// Checked -> unchecked; unchecked/partial -> checked.
|
|
return rewriteCheckbox(row, checkboxOrdinal, [](const QString &state) {
|
|
const bool checked = (state == QLatin1String("X") ||
|
|
state == QLatin1String("x"));
|
|
return checked ? QStringLiteral(" ") : QStringLiteral("X");
|
|
});
|
|
}
|
|
|
|
bool DocumentModel::cycleCheckbox(int row, int checkboxOrdinal, bool forward)
|
|
{
|
|
// Cycle order: unchecked ( ) -> partial (-) -> checked (X) -> wrap.
|
|
return rewriteCheckbox(row, checkboxOrdinal, [forward](const QString &state) {
|
|
// Normalize current state to an index in the cycle.
|
|
int cur;
|
|
if (state == QLatin1String("-"))
|
|
cur = 1; // partial
|
|
else if (state == QLatin1String("X") || state == QLatin1String("x"))
|
|
cur = 2; // checked
|
|
else
|
|
cur = 0; // unchecked
|
|
const int next = forward ? (cur + 1) % 3 : (cur + 2) % 3;
|
|
switch (next) {
|
|
case 1: return QStringLiteral("-");
|
|
case 2: return QStringLiteral("X");
|
|
default: return QStringLiteral(" ");
|
|
}
|
|
});
|
|
}
|
|
|
|
bool DocumentModel::setKeyword(int row, const QString &keyword)
|
|
{
|
|
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("/keyword"), keyword))
|
|
return false;
|
|
|
|
// Refresh the section's fields so the model reflects the written headline.
|
|
sec.fieldsLoaded = false;
|
|
loadFields(sec);
|
|
|
|
const QModelIndex idx = index(row);
|
|
emit dataChanged(idx, idx, {KeywordRole, IsTodoRole, IsDoneRole, TitleRole});
|
|
return true;
|
|
}
|
|
|
|
bool DocumentModel::cycleKeyword(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);
|
|
|
|
// Build the cycle from this document's effective keyword set, then a
|
|
// trailing empty state so cycling can clear the keyword. Reading the
|
|
// per-document /<doc>/keywords (not the global default) ensures we only
|
|
// cycle to keywords the parser recognizes for this document — otherwise an
|
|
// unrecognized keyword would merge into the title and change the section
|
|
// id. The set separates not-done and done states with '|', which is not a
|
|
// keyword.
|
|
QString doc = m_document;
|
|
if (doc.startsWith('/'))
|
|
doc = doc.mid(1);
|
|
const QString kwLine = m_client->read(QStringLiteral("/%1/keywords").arg(doc));
|
|
QStringList states;
|
|
for (const QString &tok : kwLine.split(QRegularExpression(QStringLiteral("\\s+")),
|
|
Qt::SkipEmptyParts)) {
|
|
if (tok == QLatin1String("|"))
|
|
continue;
|
|
states.append(tok);
|
|
}
|
|
if (states.isEmpty())
|
|
return false;
|
|
states.append(QString()); // trailing empty = "no keyword"
|
|
|
|
// Find the current keyword's position; default to the empty state so the
|
|
// first forward step lands on the first keyword.
|
|
int cur = states.indexOf(sec.keyword);
|
|
if (cur < 0)
|
|
cur = states.size() - 1; // treat unknown as the empty state
|
|
|
|
const int n = states.size();
|
|
const int next = forward ? (cur + 1) % n : (cur - 1 + n) % n;
|
|
return setKeyword(row, states[next]);
|
|
}
|
|
|
|
QStringList DocumentModel::keywordChoices()
|
|
{
|
|
QStringList choices;
|
|
if (!m_client)
|
|
return choices;
|
|
QString doc = m_document;
|
|
if (doc.startsWith('/'))
|
|
doc = doc.mid(1);
|
|
const QString kwLine = m_client->read(QStringLiteral("/%1/keywords").arg(doc));
|
|
for (const QString &tok : kwLine.split(QRegularExpression(QStringLiteral("\\s+")),
|
|
Qt::SkipEmptyParts)) {
|
|
if (tok == QLatin1String("|"))
|
|
continue;
|
|
choices.append(tok);
|
|
}
|
|
return choices;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
bool DocumentModel::setScheduled(int row, const QString ×tamp)
|
|
{
|
|
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("/scheduled"), timestamp))
|
|
return false;
|
|
|
|
sec.fieldsLoaded = false;
|
|
loadFields(sec);
|
|
|
|
const QModelIndex idx = index(row);
|
|
emit dataChanged(idx, idx, {ScheduledRole});
|
|
return true;
|
|
}
|
|
|
|
bool DocumentModel::setDeadline(int row, const QString ×tamp)
|
|
{
|
|
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("/deadline"), timestamp))
|
|
return false;
|
|
|
|
sec.fieldsLoaded = false;
|
|
loadFields(sec);
|
|
|
|
const QModelIndex idx = index(row);
|
|
emit dataChanged(idx, idx, {DeadlineRole});
|
|
return true;
|
|
}
|
|
|
|
int DocumentModel::revealSection(const QString §ionId, int bodyOffset)
|
|
{
|
|
if (sectionId.isEmpty() || !m_sections.contains(sectionId))
|
|
return -1;
|
|
|
|
// Walk up the parent chain and expand every ancestor so the target becomes
|
|
// visible. (The target itself is not expanded — only revealed.)
|
|
QString cur = m_sections[sectionId].parentId;
|
|
bool changed = false;
|
|
while (!cur.isEmpty() && m_sections.contains(cur)) {
|
|
if (!m_expanded.contains(cur)) {
|
|
m_expanded.insert(cur);
|
|
changed = true;
|
|
}
|
|
cur = m_sections[cur].parentId;
|
|
}
|
|
if (changed)
|
|
rebuildVisibleRows();
|
|
|
|
const int row = m_visibleRows.indexOf(sectionId);
|
|
if (row >= 0)
|
|
emit sectionRevealed(row, bodyOffset);
|
|
return row;
|
|
}
|
|
|
|
QString DocumentModel::followLink(const QString &target)
|
|
{
|
|
if (!m_client || m_document.isEmpty())
|
|
return QStringLiteral("error");
|
|
|
|
QString doc = m_document;
|
|
if (doc.startsWith('/'))
|
|
doc = doc.mid(1);
|
|
|
|
// Resolve the target via the document's /resolve file.
|
|
const QString resp =
|
|
m_client->rdwr(QStringLiteral("/%1/resolve").arg(doc), target);
|
|
|
|
// Parse the small "key: value" blob.
|
|
QString kind;
|
|
QString section;
|
|
QString targetDoc;
|
|
int offset = 0;
|
|
for (const QString &line : resp.split('\n', Qt::SkipEmptyParts)) {
|
|
const int colon = line.indexOf(':');
|
|
if (colon < 0)
|
|
continue;
|
|
const QString key = line.left(colon).trimmed();
|
|
const QString val = line.mid(colon + 1).trimmed();
|
|
if (key == QLatin1String("kind"))
|
|
kind = val;
|
|
else if (key == QLatin1String("section"))
|
|
section = val;
|
|
else if (key == QLatin1String("doc"))
|
|
targetDoc = val;
|
|
else if (key == QLatin1String("offset"))
|
|
offset = val.toInt();
|
|
}
|
|
|
|
if (kind == QLatin1String("external")) {
|
|
QDesktopServices::openUrl(QUrl(target));
|
|
} else if (kind == QLatin1String("dedicated") || kind == QLatin1String("fuzzy")) {
|
|
// The resolver may hand back a target in another document (file: links,
|
|
// globally unique id: links, cross-file headline fallback). Switch the
|
|
// model to the owning document before revealing the section.
|
|
if (!targetDoc.isEmpty() && targetDoc != doc)
|
|
setDocument(targetDoc);
|
|
|
|
if (!section.isEmpty())
|
|
revealSection(section, offset);
|
|
}
|
|
return kind.isEmpty() ? QStringLiteral("notfound") : kind;
|
|
}
|
|
|
|
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 (path needs leading /)
|
|
QString docPath = m_document.startsWith('/') ? m_document : ("/" + m_document);
|
|
loadSections(docPath, 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)
|
|
{
|
|
// Use listDirs to only get directories (actual sections), not files
|
|
QStringList entries = m_client->listDirs(parentPath);
|
|
QStringList sectionIds;
|
|
|
|
for (const QString &entry : entries) {
|
|
// Skip special directories
|
|
if (entry == "new")
|
|
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 of this section
|
|
loadSections(sec.path, entry, level + 1);
|
|
}
|
|
|
|
// Populate childIds for each section at this level
|
|
for (const QString &id : sectionIds) {
|
|
Section &sec = m_sections[id];
|
|
QStringList childEntries = m_client->listDirs(sec.path);
|
|
for (const QString &childEntry : childEntries) {
|
|
if (childEntry != "new" && m_sections.contains(childEntry) && m_sections[childEntry].parentId == id) {
|
|
sec.childIds.append(childEntry);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Store top-level section ids
|
|
if (parentId.isEmpty()) {
|
|
m_topLevelIds = sectionIds;
|
|
}
|
|
}
|
|
|
|
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::loadProperties(Section &sec) const
|
|
{
|
|
if (!m_client || sec.propertiesLoaded)
|
|
return;
|
|
|
|
QString propsStr = m_client->read(sec.path + "/properties").trimmed();
|
|
if (!propsStr.isEmpty()) {
|
|
for (const QString &line : propsStr.split('\n')) {
|
|
int eq = line.indexOf('=');
|
|
if (eq > 0) {
|
|
QString key = line.left(eq).trimmed();
|
|
QString value = line.mid(eq + 1).trimmed();
|
|
sec.properties[key] = value;
|
|
}
|
|
}
|
|
}
|
|
sec.propertiesLoaded = 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();
|
|
}
|
|
}
|