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);
|
this, &MailListView::onDisplayModeChanged);
|
||||||
filterLayout->addWidget(m_displayModeCombo);
|
filterLayout->addWidget(m_displayModeCombo);
|
||||||
|
|
||||||
// Group by combo
|
// Grouping toggles (Wino-style, orthogonal/independent)
|
||||||
m_groupByCombo = new QComboBox();
|
m_groupByDateCheck = new QCheckBox(tr("Por fecha"));
|
||||||
m_groupByCombo->addItem("Sin agrupación");
|
m_groupByDateCheck->setToolTip(tr("Agrupar la lista por fecha (Hoy, Ayer, Semana...); se combina con conversaciones"));
|
||||||
m_groupByCombo->addItem("Agrupar por fecha");
|
m_groupByDateCheck->setChecked(true);
|
||||||
m_groupByCombo->addItem("Agrupar por conversación");
|
m_groupByDateCheck->setStyleSheet("QCheckBox { font-size:12px; color:#3a3a3c; }");
|
||||||
m_groupByCombo->addItem("Agrupar por remitente");
|
m_groupByThreadCheck = new QCheckBox(tr("Conversaciones"));
|
||||||
m_groupByCombo->setFixedWidth(180);
|
m_groupByThreadCheck->setToolTip(tr("Colapsar los correos del mismo hilo en una conversación (independiente de la agrupación por fecha)"));
|
||||||
m_groupByCombo->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
|
m_groupByThreadCheck->setChecked(true);
|
||||||
m_groupByCombo->setStyleSheet(
|
m_groupByThreadCheck->setStyleSheet("QCheckBox { font-size:12px; color:#3a3a3c; }");
|
||||||
"QComboBox { border: 1px solid #d1d1d6; border-radius: 4px; padding: 4px 8px; "
|
connect(m_groupByDateCheck, &QCheckBox::toggled, this, &MailListView::onGroupingChanged);
|
||||||
"background: white; min-height: 20px; }"
|
connect(m_groupByThreadCheck, &QCheckBox::toggled, this, &MailListView::onGroupingChanged);
|
||||||
"QComboBox:hover { border-color: #bdbdbd; }"
|
filterLayout->addWidget(new QLabel(tr("Agrupar:")));
|
||||||
"QComboBox:focus { border-color: #1976D2; }"
|
filterLayout->addWidget(m_groupByDateCheck);
|
||||||
);
|
filterLayout->addWidget(m_groupByThreadCheck);
|
||||||
connect(m_groupByCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
|
|
||||||
this, &MailListView::onGroupByChanged);
|
|
||||||
filterLayout->addWidget(m_groupByCombo);
|
|
||||||
|
|
||||||
layout->addWidget(filterBar);
|
layout->addWidget(filterBar);
|
||||||
|
|
||||||
@@ -244,12 +241,16 @@ void MailListView::setupUI()
|
|||||||
);
|
);
|
||||||
|
|
||||||
m_treeModel = new EmailTreeModel(this);
|
m_treeModel = new EmailTreeModel(this);
|
||||||
// Wino-style default: group inbox by conversation (thread)
|
// Wino-style default: group by date AND collapse conversations (orthogonal).
|
||||||
m_treeModel->setGroupMode(EmailTreeModel::GroupByThread);
|
m_treeModel->setGroupByDate(true);
|
||||||
// Reflect the default in the group combo (model already set, block to avoid re-trigger)
|
m_treeModel->setGroupByThread(true);
|
||||||
m_groupByCombo->blockSignals(true);
|
// Reflect defaults in the toggles (model already set, block to avoid re-trigger)
|
||||||
m_groupByCombo->setCurrentIndex(2); // "Agrupar por conversación"
|
m_groupByDateCheck->blockSignals(true);
|
||||||
m_groupByCombo->blockSignals(false);
|
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 = new QSortFilterProxyModel(this);
|
||||||
m_proxyModel->setSourceModel(m_treeModel);
|
m_proxyModel->setSourceModel(m_treeModel);
|
||||||
m_proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
|
m_proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
|
||||||
@@ -434,7 +435,8 @@ void MailListView::updateFilterBarVisibility(int viewportWidth)
|
|||||||
bool showDisplayMode = viewportWidth > 600;
|
bool showDisplayMode = viewportWidth > 600;
|
||||||
bool showPivot = viewportWidth > 500;
|
bool showPivot = viewportWidth > 500;
|
||||||
|
|
||||||
m_groupByCombo->setVisible(showGroupBy);
|
m_groupByDateCheck->setVisible(showGroupBy);
|
||||||
|
m_groupByThreadCheck->setVisible(showGroupBy);
|
||||||
m_displayModeCombo->setVisible(showDisplayMode);
|
m_displayModeCombo->setVisible(showDisplayMode);
|
||||||
m_pivotCombo->setVisible(showPivot && m_pivotCombo->isVisible()); // respect folder-type visibility
|
m_pivotCombo->setVisible(showPivot && m_pivotCombo->isVisible()); // respect folder-type visibility
|
||||||
}
|
}
|
||||||
@@ -532,23 +534,16 @@ void MailListView::onFilterChanged()
|
|||||||
m_sourceModel->setShowPinnedOnly(m_pinnedOnlyCheck->isChecked());
|
m_sourceModel->setShowPinnedOnly(m_pinnedOnlyCheck->isChecked());
|
||||||
}
|
}
|
||||||
|
|
||||||
void MailListView::onGroupByChanged(int index)
|
void MailListView::onGroupingChanged()
|
||||||
{
|
{
|
||||||
if (!m_treeModel) return;
|
if (!m_treeModel) return;
|
||||||
switch (index) {
|
// Orthogonal toggles: date grouping and conversation collapse combine freely
|
||||||
case 0: // Sin agrupación
|
// (Wino-style). Sender grouping is offered separately when date is off.
|
||||||
m_treeModel->setGroupMode(EmailTreeModel::NoGrouping);
|
bool dateOn = m_groupByDateCheck ? m_groupByDateCheck->isChecked() : true;
|
||||||
break;
|
bool threadOn = m_groupByThreadCheck ? m_groupByThreadCheck->isChecked() : true;
|
||||||
case 1: // Agrupar por fecha
|
|
||||||
m_treeModel->setGroupMode(EmailTreeModel::GroupByDate);
|
m_treeModel->setGroupByDate(dateOn);
|
||||||
break;
|
m_treeModel->setGroupByThread(threadOn);
|
||||||
case 2: // Agrupar por conversación/hilo
|
|
||||||
m_treeModel->setGroupMode(EmailTreeModel::GroupByThread);
|
|
||||||
break;
|
|
||||||
case 3: // Agrupar por remitente
|
|
||||||
m_treeModel->setGroupMode(EmailTreeModel::GroupBySender);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (m_treeView) m_treeView->expandAll();
|
if (m_treeView) m_treeView->expandAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ private slots:
|
|||||||
void onRowSelected(const QModelIndex ¤t, const QModelIndex &previous);
|
void onRowSelected(const QModelIndex ¤t, const QModelIndex &previous);
|
||||||
void onSearchTextChanged(const QString &text);
|
void onSearchTextChanged(const QString &text);
|
||||||
void onFilterChanged();
|
void onFilterChanged();
|
||||||
void onGroupByChanged(int index);
|
void onGroupingChanged();
|
||||||
void onCompactFlagRequested(int mailId);
|
void onCompactFlagRequested(int mailId);
|
||||||
void onCompactDeleteRequested(int mailId);
|
void onCompactDeleteRequested(int mailId);
|
||||||
void onCompactCategoryRequested(int mailId);
|
void onCompactCategoryRequested(int mailId);
|
||||||
@@ -119,7 +119,8 @@ private:
|
|||||||
QCheckBox *m_selectAllCheck; // Select all checkbox in action bar
|
QCheckBox *m_selectAllCheck; // Select all checkbox in action bar
|
||||||
QWidget *m_actionBar; // Action bar widget
|
QWidget *m_actionBar; // Action bar widget
|
||||||
QToolButton *m_onlineSearchBtn; // Online search button
|
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_displayModeCombo;
|
||||||
QComboBox *m_pivotCombo; // Focused/Other pivot
|
QComboBox *m_pivotCombo; // Focused/Other pivot
|
||||||
CompactMailDelegate *m_compactDelegate = nullptr;
|
CompactMailDelegate *m_compactDelegate = nullptr;
|
||||||
|
|||||||
@@ -288,6 +288,40 @@ void EmailTreeModel::setGroupMode(GroupMode mode)
|
|||||||
endResetModel();
|
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)
|
void EmailTreeModel::setThreadExpanded(const QString& threadId, bool expanded)
|
||||||
{
|
{
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
@@ -317,7 +351,19 @@ bool EmailTreeModel::isThreadExpanded(const QString& threadId) const
|
|||||||
|
|
||||||
void EmailTreeModel::setupModelData()
|
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
|
// Flat list - all mails directly under root
|
||||||
for (int i = 0; i < m_emails.size(); ++i) {
|
for (int i = 0; i < m_emails.size(); ++i) {
|
||||||
if (i < 0 || i >= m_emails.size()) continue;
|
if (i < 0 || i >= m_emails.size()) continue;
|
||||||
@@ -326,15 +372,91 @@ void EmailTreeModel::setupModelData()
|
|||||||
mailItem->row = m_rootItem->children.size();
|
mailItem->row = m_rootItem->children.size();
|
||||||
m_rootItem->children.append(mailItem);
|
m_rootItem->children.append(mailItem);
|
||||||
}
|
}
|
||||||
} else if (m_groupMode == GroupByDate) {
|
}
|
||||||
// Grouped by date
|
|
||||||
buildGroups();
|
// Orthogonal: if conversation grouping is on, collapse threads within each
|
||||||
} else if (m_groupMode == GroupByThread) {
|
// top-level group (or the root if flat).
|
||||||
// Grouped by thread/conversation
|
if (threadOn)
|
||||||
buildThreads();
|
collapseThreads(m_rootItem);
|
||||||
} else if (m_groupMode == GroupBySender) {
|
}
|
||||||
// Grouped by sender
|
|
||||||
buildSenderGroups();
|
/// 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 setEmails(const QVector<MailItem> &emails);
|
||||||
void clear();
|
void clear();
|
||||||
|
|
||||||
// Grouping
|
// Grouping (Wino-style: date grouping is orthogonal to conversation grouping).
|
||||||
enum GroupMode {
|
enum GroupMode {
|
||||||
NoGrouping = 0,
|
NoGrouping = 0,
|
||||||
GroupByDate = 1,
|
GroupByDate = 1,
|
||||||
@@ -57,6 +57,16 @@ public:
|
|||||||
void setGroupMode(GroupMode mode);
|
void setGroupMode(GroupMode mode);
|
||||||
GroupMode groupMode() const { return m_groupMode; }
|
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
|
// Thread expansion
|
||||||
void setThreadExpanded(const QString& threadId, bool expanded);
|
void setThreadExpanded(const QString& threadId, bool expanded);
|
||||||
bool isThreadExpanded(const QString& threadId) const;
|
bool isThreadExpanded(const QString& threadId) const;
|
||||||
@@ -77,7 +87,10 @@ private:
|
|||||||
~TreeItem() { qDeleteAll(children); }
|
~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;
|
QVector<MailItem> m_emails;
|
||||||
TreeItem* m_rootItem = nullptr;
|
TreeItem* m_rootItem = nullptr;
|
||||||
QSet<QString> m_expandedThreads;
|
QSet<QString> m_expandedThreads;
|
||||||
@@ -91,4 +104,7 @@ private:
|
|||||||
void deleteTree(TreeItem* item);
|
void deleteTree(TreeItem* item);
|
||||||
void buildThreadTree();
|
void buildThreadTree();
|
||||||
QString extractThreadId(const MailItem& mail) const;
|
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