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:
2026-09-04 22:12:00 +02:00
parent 8ef1faf8ed
commit 7865e1b451
4 changed files with 187 additions and 53 deletions
+34 -39
View File
@@ -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();
}
+3 -2
View File
@@ -61,7 +61,7 @@ private slots:
void onRowSelected(const QModelIndex &current, 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;
+132 -10
View File
@@ -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;
}
}
+18 -2
View File
@@ -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);
};