feat: add manual group reordering with drag-and-drop

Assisted-by: opencode:deepseek-v4-flash-free
Signed-off-by: yanxiatao <1960024760@qq.com>
This commit is contained in:
yanxiatao 2026-06-24 03:51:01 +08:00
parent f654ce8212
commit 46d296a398
4 changed files with 205 additions and 5 deletions

View file

@ -145,6 +145,32 @@ QStringList InstanceList::getLinkedInstancesById(const QString& id) const
return linkedInstances;
}
QStringList InstanceList::groupOrder() const
{
return m_groupOrder;
}
void InstanceList::moveGroup(const QString& group, int toIndex)
{
int fromIndex = m_groupOrder.indexOf(group);
qDebug() << "moveGroup: enter" << group << "from" << fromIndex << "to" << toIndex << "order before" << m_groupOrder;
if (fromIndex < 0)
return;
int size = m_groupOrder.size();
toIndex = qBound(0, toIndex, size);
if (fromIndex == toIndex)
return;
QString name = m_groupOrder.takeAt(fromIndex);
// After takeAt, insert position shifts if target was after the removed position
int insertAt = (fromIndex < toIndex) ? (toIndex - 1) : toIndex;
m_groupOrder.insert(insertAt, name);
qDebug() << "moveGroup: order after" << m_groupOrder;
emit groupOrderChanged();
saveGroupList();
}
int InstanceList::rowCount(const QModelIndex& parent) const
{
Q_UNUSED(parent);
@ -293,6 +319,8 @@ void InstanceList::deleteGroup(const GroupId& name)
}
if (removed)
saveGroupList();
m_groupOrder.removeAll(name);
emit groupOrderChanged();
}
void InstanceList::renameGroup(const QString& src, const QString& dst)
@ -318,6 +346,10 @@ void InstanceList::renameGroup(const QString& src, const QString& dst)
}
if (modified)
saveGroupList();
int idx = m_groupOrder.indexOf(src);
if (idx >= 0)
m_groupOrder[idx] = dst;
emit groupOrderChanged();
}
bool InstanceList::isGroupCollapsed(const QString& group)
@ -692,7 +724,11 @@ void InstanceList::increaseGroupCount(const QString& group)
if (group.isEmpty())
return;
bool wasNew = !m_groupNameCache.contains(group);
++m_groupNameCache[group];
if (wasNew && !m_groupOrder.contains(group)) {
m_groupOrder.append(group);
}
}
void InstanceList::decreaseGroupCount(const QString& group)
@ -703,6 +739,7 @@ void InstanceList::decreaseGroupCount(const QString& group)
if (--m_groupNameCache[group] < 1) {
m_groupNameCache.remove(group);
m_collapsedGroups.remove(group);
m_groupOrder.removeAll(group);
}
}
@ -757,6 +794,16 @@ void InstanceList::saveGroupList()
ungrouped.insert("hidden", QJsonValue(true));
toplevel.insert("ungrouped", ungrouped);
}
// Save group order (deduplicate to prevent silent corruption)
QJsonArray orderArr;
QStringList savedOrder;
for (const auto& name : m_groupOrder) {
if (!savedOrder.contains(name) && (name.isEmpty() || m_groupNameCache.contains(name))) {
savedOrder.append(name);
orderArr.append(name);
}
}
toplevel.insert("groupOrder", orderArr);
QJsonDocument doc(toplevel);
try {
FS::write(groupFileName, doc.toJson());
@ -772,9 +819,13 @@ void InstanceList::loadGroupList()
QString groupFileName = m_instDir + "/instgroups.json";
// if there's no group file, fail
if (!QFileInfo(groupFileName).exists())
// if there's no group file, set up default state
if (!QFileInfo(groupFileName).exists()) {
m_groupOrder.clear();
m_groupOrder.append("");
m_groupsLoaded = true;
return;
}
QByteArray jsonData;
try {
@ -815,6 +866,7 @@ void InstanceList::loadGroupList()
m_instanceGroupIndex.clear();
m_groupNameCache.clear();
m_groupOrder.clear();
// Iterate through all the groups.
QJsonObject groupMapping = rootObj.value("groups").toObject();
@ -862,6 +914,32 @@ void InstanceList::loadGroupList()
// empty string represents ungrouped "group"
m_collapsedGroups.insert("");
}
// Load group order (added in formatVersion 1, optional)
if (rootObj.value("groupOrder").isArray()) {
QJsonArray orderArr = rootObj.value("groupOrder").toArray();
for (const auto& val : orderArr) {
QString name = val.toString();
m_groupOrder.append(name);
}
}
// Deduplicate group order (defense against previously-corrupted data)
QStringList uniqueOrder;
for (const auto& name : m_groupOrder) {
if (!uniqueOrder.contains(name))
uniqueOrder.append(name);
}
m_groupOrder = uniqueOrder;
// Treat ungrouped group (empty string) as a regular group for ordering
if (m_groupOrder.isEmpty()) {
m_groupOrder = m_groupNameCache.keys();
m_groupOrder.sort();
}
if (!m_groupOrder.contains(""))
m_groupOrder.append("");
m_groupsLoaded = true;
qDebug() << "Group list loaded.";
}

View file

@ -158,8 +158,11 @@ class InstanceList : public QAbstractListModel {
QMimeData* mimeData(const QModelIndexList& indexes) const override;
QStringList getLinkedInstancesById(const QString& id) const;
QStringList groupOrder() const;
void moveGroup(const QString& group, int toIndex);
signals:
void groupOrderChanged();
void dataIsInvalid();
void instancesChanged();
void instanceSelectRequest(QString instanceId);
@ -195,6 +198,7 @@ class InstanceList : public QAbstractListModel {
std::vector<std::unique_ptr<BaseInstance>> m_instances;
// id -> refs
QMap<QString, int> m_groupNameCache;
QStringList m_groupOrder;
SettingsObject* m_globalSettings;
QString m_instDir;

View file

@ -38,6 +38,7 @@
#include <QAccessible>
#include <QApplication>
#include <QCache>
#include <QDebug>
#include <QDrag>
#include <QFont>
#include <QListView>
@ -46,6 +47,7 @@
#include <QPainter>
#include <QPersistentModelIndex>
#include <QScrollBar>
#include <QSet>
#include <QtMath>
#include "VisualGroup.h"
@ -202,7 +204,23 @@ void InstanceView::updateGeometries()
}
qDeleteAll(m_groups);
m_groups = cats.values();
m_groups.clear();
// Build ordered group list from custom group order
auto instanceList = APPLICATION->instances();
auto groupOrder = instanceList->groupOrder();
QSet<QString> placed;
for (const auto& groupName : groupOrder) {
if (cats.contains(groupName)) {
m_groups.append(cats.take(groupName));
placed.insert(groupName);
}
}
// Append remaining groups (not in order) alphabetically
auto remaining = cats.values();
std::sort(remaining.begin(), remaining.end(), [](VisualGroup* a, VisualGroup* b) { return a->text.localeAwareCompare(b->text) < 0; });
m_groups.append(remaining);
updateScrollbar();
viewport()->update();
}
@ -283,12 +301,22 @@ void InstanceView::mousePressEvent(QMouseEvent* event)
m_pressedIndex = index;
m_pressedAlreadySelected = selectionModel()->isSelected(m_pressedIndex);
m_pressedPosition = geometryPos;
m_draggingGroup = false;
m_draggedGroupName.clear();
m_groupDragTargetIndex = -1;
if (event->button() == Qt::LeftButton) {
VisualGroup::HitResults hitResult;
m_pressedCategory = categoryAt(geometryPos, hitResult);
if (m_pressedCategory && hitResult & VisualGroup::CheckboxHit) {
setState(m_pressedCategory->collapsed ? ExpandingState : CollapsingState);
if (m_pressedCategory && hitResult & VisualGroup::HeaderHit) {
// Always allow drag from header, collapse also possible on click
m_draggingGroup = true;
m_draggedGroupName = m_pressedCategory->text;
qDebug() << "drag group: press on header" << m_draggedGroupName << "hitResult" << hitResult << "geometryPos" << geometryPos
<< "visualPos" << visualPos;
if (hitResult & VisualGroup::CheckboxHit) {
setState(m_pressedCategory->collapsed ? ExpandingState : CollapsingState);
}
event->accept();
return;
}
@ -325,6 +353,43 @@ void InstanceView::mouseMoveEvent(QMouseEvent* event)
QPoint visualPos = event->pos();
QPoint geometryPos = event->pos() + offset();
if (m_draggingGroup) {
topLeft = m_pressedPosition - offset();
qreal dist = (topLeft - event->pos()).manhattanLength();
qreal threshold = QApplication::startDragDistance();
qDebug() << "drag group: mouseMove m_draggingGroup" << m_draggedGroupName << "dist" << dist << "threshold" << threshold
<< "m_pressedPosition" << m_pressedPosition << "offset" << offset() << "event->pos()" << event->pos() << "topLeft"
<< topLeft;
if (dist > threshold) {
// Drag preview: compute visual target index and show indicator
setState(NoState);
QPoint cursorPos = geometryPos;
m_groupDragTargetIndex = -1;
for (int i = 0; i < m_groups.size(); i++) {
if (m_groups[i]->text == m_draggedGroupName)
continue;
int gTop = m_groups[i]->verticalPosition();
int gBot = gTop + m_groups[i]->totalHeight();
int hMid = gTop + VisualGroup::headerHeight() / 2;
if (cursorPos.y() < hMid) {
m_groupDragTargetIndex = i;
break;
} else if (cursorPos.y() < gBot) {
m_groupDragTargetIndex = i + 1;
break;
}
}
if (m_groupDragTargetIndex < 0)
m_groupDragTargetIndex = m_groups.size();
if (m_groupDragTargetIndex > m_groups.size())
m_groupDragTargetIndex = m_groups.size();
qDebug() << "drag group: targetIndex" << m_groupDragTargetIndex;
viewport()->update();
return;
}
return;
}
if (state() == ExpandingState || state() == CollapsingState) {
return;
}
@ -368,6 +433,39 @@ void InstanceView::mouseReleaseEvent(QMouseEvent* event)
{
executeDelayedItemsLayout();
qDebug() << "drag group: mouseRelease draggingGroup was" << m_draggingGroup << "state" << state() << "pressedCategory"
<< (m_pressedCategory ? m_pressedCategory->text : "(null)");
// Commit group drag reorder on release
if (m_draggingGroup && m_groupDragTargetIndex >= 0) {
auto groupOrder = APPLICATION->instances()->groupOrder();
int currentOrderIdx = groupOrder.indexOf(m_draggedGroupName);
if (currentOrderIdx >= 0) {
// Convert visual target index to groupOrder index
int orderTarget = 0;
for (int i = 0; i < m_groupDragTargetIndex && i < m_groups.size(); i++) {
if (m_groups[i]->text == m_draggedGroupName)
continue;
int orderPos = groupOrder.indexOf(m_groups[i]->text);
if (orderPos >= 0 && orderPos >= orderTarget)
orderTarget = orderPos + 1;
}
// Clamp to valid range
if (orderTarget > groupOrder.size())
orderTarget = groupOrder.size();
if (orderTarget != currentOrderIdx) {
qDebug() << "drag group: commit" << m_draggedGroupName << "from" << currentOrderIdx << "to" << orderTarget;
APPLICATION->instances()->moveGroup(m_draggedGroupName, orderTarget);
updateGeometries();
}
}
viewport()->update();
}
m_draggingGroup = false;
m_draggedGroupName.clear();
m_groupDragTargetIndex = -1;
QPoint visualPos = event->pos();
QPoint geometryPos = event->pos() + offset();
QPersistentModelIndex index = indexAt(visualPos);
@ -524,6 +622,21 @@ void InstanceView::paintEvent([[maybe_unused]] QPaintEvent* event)
option.rect = backup;
}
// Draw group drag drop indicator
if (m_groupDragTargetIndex >= 0) {
int indicatorY = 0;
if (m_groupDragTargetIndex < m_groups.size()) {
indicatorY = m_groups[m_groupDragTargetIndex]->verticalPosition() - verticalOffset();
} else if (!m_groups.isEmpty()) {
auto* last = m_groups.last();
indicatorY = last->verticalPosition() + last->totalHeight() - verticalOffset();
}
painter.save();
painter.setPen(QPen(Qt::white, 1));
painter.drawLine(m_leftMargin, indicatorY, wpWidth - m_rightMargin, indicatorY);
painter.restore();
}
for (int i = 0; i < model()->rowCount(); ++i) {
const QModelIndex index = model()->index(i, 0);
if (isIndexHidden(index)) {

View file

@ -139,6 +139,11 @@ class InstanceView : public QAbstractItemView {
QItemSelectionModel::SelectionFlag m_ctrlDragSelectionFlag;
QPoint m_lastDragPosition;
// group drag-reorder state
bool m_draggingGroup = false;
QString m_draggedGroupName;
int m_groupDragTargetIndex = -1;
VisualGroup* category(const QModelIndex& index) const;
VisualGroup* category(const QString& cat) const;
VisualGroup* categoryAt(const QPoint& pos, VisualGroup::HitResults& result) const;