feat: orthogonal date + conversation grouping (Wino-style)
Were mutually exclusive (GroupMode chose ONE). In Wino/Outlook/Gmail the conversation grouping is an independent flag that combines with date grouping (Hoy/Ayer/Semana...) — conversations collapse inside each date group. EmailTreeModel: - Add independent setGroupByDate / setGroupByThread toggles (+ defaults) - setupModelData: date grouping is the base; collapseThreads() is applied orthogonally after any grouping to collapse threads within each group - collapseThreads: creates fresh nodes (fixes latent double-free from my earlier attempt), recurses into subgroups, single-message threads stay plain mail rows under the date group - m_groupMode default -> GroupByDate MailListView: - Replace exclusive group-by combo with two independent checkboxes: 'Por fecha' + 'Conversaciones' (both on by default) - onGroupingChanged reads both and applies to the model Verified against user's real DB (162 mails): date groups (10) + threads collapse inside them; 5x repeat runs stable (no crash / double-free).
This commit is contained in:
+34
-39
@@ -133,23 +133,20 @@ void MailListView::setupUI()
|
||||
this, &MailListView::onDisplayModeChanged);
|
||||
filterLayout->addWidget(m_displayModeCombo);
|
||||
|
||||
// Group by combo
|
||||
m_groupByCombo = new QComboBox();
|
||||
m_groupByCombo->addItem("Sin agrupación");
|
||||
m_groupByCombo->addItem("Agrupar por fecha");
|
||||
m_groupByCombo->addItem("Agrupar por conversación");
|
||||
m_groupByCombo->addItem("Agrupar por remitente");
|
||||
m_groupByCombo->setFixedWidth(180);
|
||||
m_groupByCombo->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
|
||||
m_groupByCombo->setStyleSheet(
|
||||
"QComboBox { border: 1px solid #d1d1d6; border-radius: 4px; padding: 4px 8px; "
|
||||
"background: white; min-height: 20px; }"
|
||||
"QComboBox:hover { border-color: #bdbdbd; }"
|
||||
"QComboBox:focus { border-color: #1976D2; }"
|
||||
);
|
||||
connect(m_groupByCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
|
||||
this, &MailListView::onGroupByChanged);
|
||||
filterLayout->addWidget(m_groupByCombo);
|
||||
// Grouping toggles (Wino-style, orthogonal/independent)
|
||||
m_groupByDateCheck = new QCheckBox(tr("Por fecha"));
|
||||
m_groupByDateCheck->setToolTip(tr("Agrupar la lista por fecha (Hoy, Ayer, Semana...); se combina con conversaciones"));
|
||||
m_groupByDateCheck->setChecked(true);
|
||||
m_groupByDateCheck->setStyleSheet("QCheckBox { font-size:12px; color:#3a3a3c; }");
|
||||
m_groupByThreadCheck = new QCheckBox(tr("Conversaciones"));
|
||||
m_groupByThreadCheck->setToolTip(tr("Colapsar los correos del mismo hilo en una conversación (independiente de la agrupación por fecha)"));
|
||||
m_groupByThreadCheck->setChecked(true);
|
||||
m_groupByThreadCheck->setStyleSheet("QCheckBox { font-size:12px; color:#3a3a3c; }");
|
||||
connect(m_groupByDateCheck, &QCheckBox::toggled, this, &MailListView::onGroupingChanged);
|
||||
connect(m_groupByThreadCheck, &QCheckBox::toggled, this, &MailListView::onGroupingChanged);
|
||||
filterLayout->addWidget(new QLabel(tr("Agrupar:")));
|
||||
filterLayout->addWidget(m_groupByDateCheck);
|
||||
filterLayout->addWidget(m_groupByThreadCheck);
|
||||
|
||||
layout->addWidget(filterBar);
|
||||
|
||||
@@ -244,12 +241,16 @@ void MailListView::setupUI()
|
||||
);
|
||||
|
||||
m_treeModel = new EmailTreeModel(this);
|
||||
// Wino-style default: group inbox by conversation (thread)
|
||||
m_treeModel->setGroupMode(EmailTreeModel::GroupByThread);
|
||||
// Reflect the default in the group combo (model already set, block to avoid re-trigger)
|
||||
m_groupByCombo->blockSignals(true);
|
||||
m_groupByCombo->setCurrentIndex(2); // "Agrupar por conversación"
|
||||
m_groupByCombo->blockSignals(false);
|
||||
// Wino-style default: group by date AND collapse conversations (orthogonal).
|
||||
m_treeModel->setGroupByDate(true);
|
||||
m_treeModel->setGroupByThread(true);
|
||||
// Reflect defaults in the toggles (model already set, block to avoid re-trigger)
|
||||
m_groupByDateCheck->blockSignals(true);
|
||||
m_groupByDateCheck->setChecked(true);
|
||||
m_groupByDateCheck->blockSignals(false);
|
||||
m_groupByThreadCheck->blockSignals(true);
|
||||
m_groupByThreadCheck->setChecked(true);
|
||||
m_groupByThreadCheck->blockSignals(false);
|
||||
m_proxyModel = new QSortFilterProxyModel(this);
|
||||
m_proxyModel->setSourceModel(m_treeModel);
|
||||
m_proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
|
||||
@@ -434,7 +435,8 @@ void MailListView::updateFilterBarVisibility(int viewportWidth)
|
||||
bool showDisplayMode = viewportWidth > 600;
|
||||
bool showPivot = viewportWidth > 500;
|
||||
|
||||
m_groupByCombo->setVisible(showGroupBy);
|
||||
m_groupByDateCheck->setVisible(showGroupBy);
|
||||
m_groupByThreadCheck->setVisible(showGroupBy);
|
||||
m_displayModeCombo->setVisible(showDisplayMode);
|
||||
m_pivotCombo->setVisible(showPivot && m_pivotCombo->isVisible()); // respect folder-type visibility
|
||||
}
|
||||
@@ -532,23 +534,16 @@ void MailListView::onFilterChanged()
|
||||
m_sourceModel->setShowPinnedOnly(m_pinnedOnlyCheck->isChecked());
|
||||
}
|
||||
|
||||
void MailListView::onGroupByChanged(int index)
|
||||
void MailListView::onGroupingChanged()
|
||||
{
|
||||
if (!m_treeModel) return;
|
||||
switch (index) {
|
||||
case 0: // Sin agrupación
|
||||
m_treeModel->setGroupMode(EmailTreeModel::NoGrouping);
|
||||
break;
|
||||
case 1: // Agrupar por fecha
|
||||
m_treeModel->setGroupMode(EmailTreeModel::GroupByDate);
|
||||
break;
|
||||
case 2: // Agrupar por conversación/hilo
|
||||
m_treeModel->setGroupMode(EmailTreeModel::GroupByThread);
|
||||
break;
|
||||
case 3: // Agrupar por remitente
|
||||
m_treeModel->setGroupMode(EmailTreeModel::GroupBySender);
|
||||
break;
|
||||
}
|
||||
// Orthogonal toggles: date grouping and conversation collapse combine freely
|
||||
// (Wino-style). Sender grouping is offered separately when date is off.
|
||||
bool dateOn = m_groupByDateCheck ? m_groupByDateCheck->isChecked() : true;
|
||||
bool threadOn = m_groupByThreadCheck ? m_groupByThreadCheck->isChecked() : true;
|
||||
|
||||
m_treeModel->setGroupByDate(dateOn);
|
||||
m_treeModel->setGroupByThread(threadOn);
|
||||
if (m_treeView) m_treeView->expandAll();
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ private slots:
|
||||
void onRowSelected(const QModelIndex ¤t, const QModelIndex &previous);
|
||||
void onSearchTextChanged(const QString &text);
|
||||
void onFilterChanged();
|
||||
void onGroupByChanged(int index);
|
||||
void onGroupingChanged();
|
||||
void onCompactFlagRequested(int mailId);
|
||||
void onCompactDeleteRequested(int mailId);
|
||||
void onCompactCategoryRequested(int mailId);
|
||||
@@ -119,7 +119,8 @@ private:
|
||||
QCheckBox *m_selectAllCheck; // Select all checkbox in action bar
|
||||
QWidget *m_actionBar; // Action bar widget
|
||||
QToolButton *m_onlineSearchBtn; // Online search button
|
||||
QComboBox *m_groupByCombo;
|
||||
QCheckBox *m_groupByDateCheck; // independent: group by date (Hoy/Ayer/...)
|
||||
QCheckBox *m_groupByThreadCheck; // independent: collapse conversations
|
||||
QComboBox *m_displayModeCombo;
|
||||
QComboBox *m_pivotCombo; // Focused/Other pivot
|
||||
CompactMailDelegate *m_compactDelegate = nullptr;
|
||||
|
||||
@@ -288,6 +288,40 @@ void EmailTreeModel::setGroupMode(GroupMode mode)
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
// Orthogonal grouping toggles (Wino-style: each on/off independently).
|
||||
void EmailTreeModel::setGroupByDate(bool on)
|
||||
{
|
||||
if (m_groupByDate == on) return;
|
||||
beginResetModel();
|
||||
m_groupByDate = on;
|
||||
qDeleteAll(m_rootItem->children);
|
||||
m_rootItem->children.clear();
|
||||
setupModelData();
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
void EmailTreeModel::setGroupByThread(bool on)
|
||||
{
|
||||
if (m_groupByThread == on) return;
|
||||
beginResetModel();
|
||||
m_groupByThread = on;
|
||||
qDeleteAll(m_rootItem->children);
|
||||
m_rootItem->children.clear();
|
||||
setupModelData();
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
void EmailTreeModel::setGroupBySender(bool on)
|
||||
{
|
||||
if (m_groupBySender == on) return;
|
||||
beginResetModel();
|
||||
m_groupBySender = on;
|
||||
qDeleteAll(m_rootItem->children);
|
||||
m_rootItem->children.clear();
|
||||
setupModelData();
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
void EmailTreeModel::setThreadExpanded(const QString& threadId, bool expanded)
|
||||
{
|
||||
if (expanded) {
|
||||
@@ -317,7 +351,19 @@ bool EmailTreeModel::isThreadExpanded(const QString& threadId) const
|
||||
|
||||
void EmailTreeModel::setupModelData()
|
||||
{
|
||||
if (m_groupMode == NoGrouping) {
|
||||
bool dateBase = m_groupByDate || m_groupMode == GroupByDate || m_groupMode == NoGrouping;
|
||||
bool threadOn = m_groupByThread || m_groupMode == GroupByThread;
|
||||
bool senderOn = m_groupBySender || m_groupMode == GroupBySender;
|
||||
|
||||
// Wino-style: date grouping is the base kind, conversation (thread) collapse
|
||||
// is an independent, orthogonal flag applied inside whatever grouping is active.
|
||||
if (senderOn && !dateBase && !threadOn) {
|
||||
buildSenderGroups();
|
||||
} else if (dateBase) {
|
||||
buildGroups();
|
||||
} else if (senderOn) {
|
||||
buildSenderGroups();
|
||||
} else {
|
||||
// Flat list - all mails directly under root
|
||||
for (int i = 0; i < m_emails.size(); ++i) {
|
||||
if (i < 0 || i >= m_emails.size()) continue;
|
||||
@@ -326,15 +372,91 @@ void EmailTreeModel::setupModelData()
|
||||
mailItem->row = m_rootItem->children.size();
|
||||
m_rootItem->children.append(mailItem);
|
||||
}
|
||||
} else if (m_groupMode == GroupByDate) {
|
||||
// Grouped by date
|
||||
buildGroups();
|
||||
} else if (m_groupMode == GroupByThread) {
|
||||
// Grouped by thread/conversation
|
||||
buildThreads();
|
||||
} else if (m_groupMode == GroupBySender) {
|
||||
// Grouped by sender
|
||||
buildSenderGroups();
|
||||
}
|
||||
|
||||
// Orthogonal: if conversation grouping is on, collapse threads within each
|
||||
// top-level group (or the root if flat).
|
||||
if (threadOn)
|
||||
collapseThreads(m_rootItem);
|
||||
}
|
||||
|
||||
/// Helper: add a Mail item under the given parent.
|
||||
void EmailTreeModel::addMailItem(TreeItem* parent, int mailIndex)
|
||||
{
|
||||
if (!parent || mailIndex < 0 || mailIndex >= m_emails.size()) return;
|
||||
TreeItem* mailItem = new TreeItem(TreeItem::Mail, parent);
|
||||
mailItem->mailIndex = mailIndex;
|
||||
mailItem->row = parent->children.size();
|
||||
parent->children.append(mailItem);
|
||||
}
|
||||
|
||||
/// Collapse mails into conversation (thread) nodes under `parent`.
|
||||
/// Single-message threads stay as plain mail rows (no expander), following Wino.
|
||||
void EmailTreeModel::collapseThreads(TreeItem* parent)
|
||||
{
|
||||
if (!parent) return;
|
||||
|
||||
// Collect mail indices in present (visible) order; recurse into subgroups first.
|
||||
QVector<int> flatIndices;
|
||||
for (TreeItem* item : parent->children) {
|
||||
if (item->type == TreeItem::Group) {
|
||||
collapseThreads(item); // recurse into date/sender groups
|
||||
} else if (item->type == TreeItem::Mail && item->mailIndex >= 0) {
|
||||
flatIndices.append(item->mailIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (flatIndices.isEmpty()) return;
|
||||
|
||||
// Group by thread id (preserving encounter order).
|
||||
QVector<int> ordered;
|
||||
QSet<QString> seen;
|
||||
QMap<QString, QVector<int>> threadMap;
|
||||
for (int idx : flatIndices) {
|
||||
const MailItem& mail = m_emails[idx];
|
||||
QString tid = extractThreadId(mail);
|
||||
if (tid.isEmpty()) tid = ThreadUtils::generateThreadId(mail.messageId(), mail.subject(), mail.sender());
|
||||
threadMap[tid].append(idx);
|
||||
if (!seen.contains(tid)) { seen.insert(tid); ordered.append(idx); }
|
||||
}
|
||||
|
||||
// Rebuild this level from scratch (fresh nodes) so qDeleteAll below is safe.
|
||||
QVector<TreeItem*> newChildren;
|
||||
for (int leadIdx : ordered) {
|
||||
const MailItem& lead = m_emails[leadIdx];
|
||||
QString tid = extractThreadId(lead);
|
||||
if (tid.isEmpty()) tid = ThreadUtils::generateThreadId(lead.messageId(), lead.subject(), lead.sender());
|
||||
const QVector<int>& members = threadMap[tid];
|
||||
|
||||
if (members.size() <= 1) {
|
||||
// Single message: plain mail row (no expander).
|
||||
TreeItem* mi = new TreeItem(TreeItem::Mail, parent);
|
||||
mi->mailIndex = members.first();
|
||||
mi->row = newChildren.size();
|
||||
newChildren.append(mi);
|
||||
} else {
|
||||
// Multi-message thread: a collapsible conversation node with children.
|
||||
TreeItem* ti = new TreeItem(TreeItem::Thread, parent);
|
||||
ti->threadId = tid;
|
||||
ti->groupIndex = newChildren.size();
|
||||
ti->expanded = m_expandedThreads.contains(tid);
|
||||
for (int mIdx : members) {
|
||||
TreeItem* mi = new TreeItem(TreeItem::Mail, ti);
|
||||
mi->mailIndex = mIdx;
|
||||
mi->row = ti->children.size();
|
||||
ti->children.append(mi);
|
||||
}
|
||||
ti->row = newChildren.size();
|
||||
newChildren.append(ti);
|
||||
}
|
||||
}
|
||||
|
||||
// Replace existing children entirely.
|
||||
qDeleteAll(parent->children);
|
||||
parent->children = newChildren;
|
||||
for (int i = 0; i < newChildren.size(); ++i) {
|
||||
newChildren[i]->parent = parent;
|
||||
newChildren[i]->row = i;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ public:
|
||||
void setEmails(const QVector<MailItem> &emails);
|
||||
void clear();
|
||||
|
||||
// Grouping
|
||||
// Grouping (Wino-style: date grouping is orthogonal to conversation grouping).
|
||||
enum GroupMode {
|
||||
NoGrouping = 0,
|
||||
GroupByDate = 1,
|
||||
@@ -57,6 +57,16 @@ public:
|
||||
void setGroupMode(GroupMode mode);
|
||||
GroupMode groupMode() const { return m_groupMode; }
|
||||
|
||||
/// Conversation grouping is an independent, combinable flag (like Wino):
|
||||
/// when TRUE, mails sharing a thread are collapsed under a conversation node;
|
||||
/// when combined with date grouping, threads nest under the date groups.
|
||||
void setGroupByThread(bool on);
|
||||
bool groupByThread() const { return m_groupByThread; }
|
||||
void setGroupByDate(bool on);
|
||||
bool groupByDate() const { return m_groupByDate; }
|
||||
void setGroupBySender(bool on);
|
||||
bool groupBySender() const { return m_groupBySender; }
|
||||
|
||||
// Thread expansion
|
||||
void setThreadExpanded(const QString& threadId, bool expanded);
|
||||
bool isThreadExpanded(const QString& threadId) const;
|
||||
@@ -77,7 +87,10 @@ private:
|
||||
~TreeItem() { qDeleteAll(children); }
|
||||
};
|
||||
|
||||
GroupMode m_groupMode = NoGrouping;
|
||||
GroupMode m_groupMode = GroupByDate; // default: date-based (Wino-style)
|
||||
bool m_groupByDate = false; // orthogonal flags override groupMode
|
||||
bool m_groupByThread = false;
|
||||
bool m_groupBySender = false;
|
||||
QVector<MailItem> m_emails;
|
||||
TreeItem* m_rootItem = nullptr;
|
||||
QSet<QString> m_expandedThreads;
|
||||
@@ -91,4 +104,7 @@ private:
|
||||
void deleteTree(TreeItem* item);
|
||||
void buildThreadTree();
|
||||
QString extractThreadId(const MailItem& mail) const;
|
||||
// Helper + orthogonal thread-collapse (Wino-style conversation grouping)
|
||||
void addMailItem(TreeItem* parent, int mailIndex);
|
||||
void collapseThreads(TreeItem* parent);
|
||||
};
|
||||
Reference in New Issue
Block a user