summaryrefslogtreecommitdiff
path: root/meshmc/launcher/ui/instanceview
diff options
context:
space:
mode:
Diffstat (limited to 'meshmc/launcher/ui/instanceview')
-rw-r--r--meshmc/launcher/ui/instanceview/AccessibleInstanceView.cpp859
-rw-r--r--meshmc/launcher/ui/instanceview/AccessibleInstanceView.h28
-rw-r--r--meshmc/launcher/ui/instanceview/AccessibleInstanceView_p.h155
-rw-r--r--meshmc/launcher/ui/instanceview/InstanceDelegate.cpp462
-rw-r--r--meshmc/launcher/ui/instanceview/InstanceDelegate.h69
-rw-r--r--meshmc/launcher/ui/instanceview/InstanceProxyModel.cpp100
-rw-r--r--meshmc/launcher/ui/instanceview/InstanceProxyModel.h60
-rw-r--r--meshmc/launcher/ui/instanceview/InstanceView.cpp952
-rw-r--r--meshmc/launcher/ui/instanceview/InstanceView.h192
-rw-r--r--meshmc/launcher/ui/instanceview/VisualGroup.cpp334
-rw-r--r--meshmc/launcher/ui/instanceview/VisualGroup.h126
11 files changed, 3337 insertions, 0 deletions
diff --git a/meshmc/launcher/ui/instanceview/AccessibleInstanceView.cpp b/meshmc/launcher/ui/instanceview/AccessibleInstanceView.cpp
new file mode 100644
index 0000000000..af45a3fb68
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/AccessibleInstanceView.cpp
@@ -0,0 +1,859 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "InstanceView.h"
+#include "AccessibleInstanceView.h"
+#include "AccessibleInstanceView_p.h"
+
+#include <qvariant.h>
+#include <qaccessible.h>
+#include <qheaderview.h>
+
+#ifndef QT_NO_ACCESSIBILITY
+
+QAccessibleInterface* groupViewAccessibleFactory(const QString& classname,
+ QObject* object)
+{
+ QAccessibleInterface* iface = 0;
+ if (!object || !object->isWidgetType())
+ return iface;
+
+ QWidget* widget = static_cast<QWidget*>(object);
+
+ if (classname == QLatin1String("InstanceView")) {
+ iface = new AccessibleInstanceView((InstanceView*)widget);
+ }
+ return iface;
+}
+
+QAbstractItemView* AccessibleInstanceView::view() const
+{
+ return qobject_cast<QAbstractItemView*>(object());
+}
+
+int AccessibleInstanceView::logicalIndex(const QModelIndex& index) const
+{
+ if (!view()->model() || !index.isValid())
+ return -1;
+ return index.row() * (index.model()->columnCount()) + index.column();
+}
+
+AccessibleInstanceView::AccessibleInstanceView(QWidget* w)
+ : QAccessibleObject(w)
+{
+ Q_ASSERT(view());
+}
+
+bool AccessibleInstanceView::isValid() const
+{
+ return view();
+}
+
+AccessibleInstanceView::~AccessibleInstanceView()
+{
+ for (QAccessible::Id id : childToId) {
+ QAccessible::deleteAccessibleInterface(id);
+ }
+}
+
+QAccessibleInterface* AccessibleInstanceView::cellAt(int row, int column) const
+{
+ if (!view()->model()) {
+ return 0;
+ }
+
+ QModelIndex index =
+ view()->model()->index(row, column, view()->rootIndex());
+ if (Q_UNLIKELY(!index.isValid())) {
+ qWarning() << "AccessibleInstanceView::cellAt: invalid index: " << index
+ << " for " << view();
+ return 0;
+ }
+
+ return child(logicalIndex(index));
+}
+
+QAccessibleInterface* AccessibleInstanceView::caption() const
+{
+ return 0;
+}
+
+QString AccessibleInstanceView::columnDescription(int column) const
+{
+ if (!view()->model())
+ return QString();
+
+ return view()->model()->headerData(column, Qt::Horizontal).toString();
+}
+
+int AccessibleInstanceView::columnCount() const
+{
+ if (!view()->model())
+ return 0;
+ return 1;
+}
+
+int AccessibleInstanceView::rowCount() const
+{
+ if (!view()->model())
+ return 0;
+ return view()->model()->rowCount();
+}
+
+int AccessibleInstanceView::selectedCellCount() const
+{
+ if (!view()->selectionModel())
+ return 0;
+ return view()->selectionModel()->selectedIndexes().count();
+}
+
+int AccessibleInstanceView::selectedColumnCount() const
+{
+ if (!view()->selectionModel())
+ return 0;
+ return view()->selectionModel()->selectedColumns().count();
+}
+
+int AccessibleInstanceView::selectedRowCount() const
+{
+ if (!view()->selectionModel())
+ return 0;
+ return view()->selectionModel()->selectedRows().count();
+}
+
+QString AccessibleInstanceView::rowDescription(int row) const
+{
+ if (!view()->model())
+ return QString();
+ return view()->model()->headerData(row, Qt::Vertical).toString();
+}
+
+QList<QAccessibleInterface*> AccessibleInstanceView::selectedCells() const
+{
+ QList<QAccessibleInterface*> cells;
+ if (!view()->selectionModel())
+ return cells;
+ const QModelIndexList selectedIndexes =
+ view()->selectionModel()->selectedIndexes();
+ cells.reserve(selectedIndexes.size());
+ for (const QModelIndex& index : selectedIndexes)
+ cells.append(child(logicalIndex(index)));
+ return cells;
+}
+
+QList<int> AccessibleInstanceView::selectedColumns() const
+{
+ if (!view()->selectionModel()) {
+ return QList<int>();
+ }
+
+ const QModelIndexList selectedColumns =
+ view()->selectionModel()->selectedColumns();
+
+ QList<int> columns;
+ columns.reserve(selectedColumns.size());
+ for (const QModelIndex& index : selectedColumns) {
+ columns.append(index.column());
+ }
+
+ return columns;
+}
+
+QList<int> AccessibleInstanceView::selectedRows() const
+{
+ if (!view()->selectionModel()) {
+ return QList<int>();
+ }
+
+ QList<int> rows;
+
+ const QModelIndexList selectedRows =
+ view()->selectionModel()->selectedRows();
+
+ rows.reserve(selectedRows.size());
+ for (const QModelIndex& index : selectedRows) {
+ rows.append(index.row());
+ }
+
+ return rows;
+}
+
+QAccessibleInterface* AccessibleInstanceView::summary() const
+{
+ return 0;
+}
+
+bool AccessibleInstanceView::isColumnSelected(int column) const
+{
+ if (!view()->selectionModel()) {
+ return false;
+ }
+
+ return view()->selectionModel()->isColumnSelected(column, QModelIndex());
+}
+
+bool AccessibleInstanceView::isRowSelected(int row) const
+{
+ if (!view()->selectionModel()) {
+ return false;
+ }
+
+ return view()->selectionModel()->isRowSelected(row, QModelIndex());
+}
+
+bool AccessibleInstanceView::selectRow(int row)
+{
+ if (!view()->model() || !view()->selectionModel()) {
+ return false;
+ }
+ QModelIndex index = view()->model()->index(row, 0, view()->rootIndex());
+
+ if (!index.isValid() ||
+ view()->selectionBehavior() == QAbstractItemView::SelectColumns) {
+ return false;
+ }
+
+ switch (view()->selectionMode()) {
+ case QAbstractItemView::NoSelection: {
+ return false;
+ }
+ case QAbstractItemView::SingleSelection: {
+ if (view()->selectionBehavior() != QAbstractItemView::SelectRows &&
+ columnCount() > 1)
+ return false;
+ view()->clearSelection();
+ break;
+ }
+ case QAbstractItemView::ContiguousSelection: {
+ if ((!row || !view()->selectionModel()->isRowSelected(
+ row - 1, view()->rootIndex())) &&
+ !view()->selectionModel()->isRowSelected(row + 1,
+ view()->rootIndex())) {
+ view()->clearSelection();
+ }
+ break;
+ }
+ default: {
+ break;
+ }
+ }
+
+ view()->selectionModel()->select(index, QItemSelectionModel::Select |
+ QItemSelectionModel::Rows);
+ return true;
+}
+
+bool AccessibleInstanceView::selectColumn(int column)
+{
+ if (!view()->model() || !view()->selectionModel()) {
+ return false;
+ }
+ QModelIndex index = view()->model()->index(0, column, view()->rootIndex());
+
+ if (!index.isValid() ||
+ view()->selectionBehavior() == QAbstractItemView::SelectRows) {
+ return false;
+ }
+
+ switch (view()->selectionMode()) {
+ case QAbstractItemView::NoSelection: {
+ return false;
+ }
+ case QAbstractItemView::SingleSelection: {
+ if (view()->selectionBehavior() !=
+ QAbstractItemView::SelectColumns &&
+ rowCount() > 1) {
+ return false;
+ }
+ // fallthrough intentional
+ }
+ case QAbstractItemView::ContiguousSelection: {
+ if ((!column || !view()->selectionModel()->isColumnSelected(
+ column - 1, view()->rootIndex())) &&
+ !view()->selectionModel()->isColumnSelected(
+ column + 1, view()->rootIndex())) {
+ view()->clearSelection();
+ }
+ break;
+ }
+ default: {
+ break;
+ }
+ }
+
+ view()->selectionModel()->select(index, QItemSelectionModel::Select |
+ QItemSelectionModel::Columns);
+ return true;
+}
+
+bool AccessibleInstanceView::unselectRow(int row)
+{
+ if (!view()->model() || !view()->selectionModel()) {
+ return false;
+ }
+
+ QModelIndex index = view()->model()->index(row, 0, view()->rootIndex());
+ if (!index.isValid()) {
+ return false;
+ }
+
+ QItemSelection selection(index, index);
+ auto selectionModel = view()->selectionModel();
+
+ switch (view()->selectionMode()) {
+ case QAbstractItemView::SingleSelection:
+ // no unselect
+ if (selectedRowCount() == 1) {
+ return false;
+ }
+ break;
+ case QAbstractItemView::ContiguousSelection: {
+ // no unselect
+ if (selectedRowCount() == 1) {
+ return false;
+ }
+
+ if ((!row ||
+ selectionModel->isRowSelected(row - 1, view()->rootIndex())) &&
+ selectionModel->isRowSelected(row + 1, view()->rootIndex())) {
+ // If there are rows selected both up the current row and down
+ // the current rown, the ones which are down the current row
+ // will be deselected
+ selection = QItemSelection(
+ index, view()->model()->index(rowCount() - 1, 0,
+ view()->rootIndex()));
+ }
+ }
+ default: {
+ break;
+ }
+ }
+
+ selectionModel->select(selection, QItemSelectionModel::Deselect |
+ QItemSelectionModel::Rows);
+ return true;
+}
+
+bool AccessibleInstanceView::unselectColumn(int column)
+{
+ auto model = view()->model();
+ if (!model || !view()->selectionModel()) {
+ return false;
+ }
+
+ QModelIndex index = model->index(0, column, view()->rootIndex());
+ if (!index.isValid()) {
+ return false;
+ }
+
+ QItemSelection selection(index, index);
+
+ switch (view()->selectionMode()) {
+ case QAbstractItemView::SingleSelection: {
+ // In SingleSelection and ContiguousSelection once an item
+ // is selected, there's no way for the user to unselect all items
+ if (selectedColumnCount() == 1) {
+ return false;
+ }
+ break;
+ }
+ case QAbstractItemView::ContiguousSelection:
+ if (selectedColumnCount() == 1) {
+ return false;
+ }
+
+ if ((!column || view()->selectionModel()->isColumnSelected(
+ column - 1, view()->rootIndex())) &&
+ view()->selectionModel()->isColumnSelected(
+ column + 1, view()->rootIndex())) {
+ // If there are columns selected both at the left of the current
+ // row and at the right of the current row, the ones which are
+ // at the right will be deselected
+ selection =
+ QItemSelection(index, model->index(0, columnCount() - 1,
+ view()->rootIndex()));
+ }
+ default:
+ break;
+ }
+
+ view()->selectionModel()->select(selection,
+ QItemSelectionModel::Deselect |
+ QItemSelectionModel::Columns);
+ return true;
+}
+
+QAccessible::Role AccessibleInstanceView::role() const
+{
+ return QAccessible::List;
+}
+
+QAccessible::State AccessibleInstanceView::state() const
+{
+ return QAccessible::State();
+}
+
+QAccessibleInterface* AccessibleInstanceView::childAt(int x, int y) const
+{
+ QPoint viewportOffset = view()->viewport()->mapTo(view(), QPoint(0, 0));
+ QPoint indexPosition = view()->mapFromGlobal(QPoint(x, y) - viewportOffset);
+ // FIXME: if indexPosition < 0 in one coordinate, return header
+
+ QModelIndex index = view()->indexAt(indexPosition);
+ if (index.isValid()) {
+ return child(logicalIndex(index));
+ }
+ return 0;
+}
+
+int AccessibleInstanceView::childCount() const
+{
+ if (!view()->model()) {
+ return 0;
+ }
+ return (view()->model()->rowCount()) * (view()->model()->columnCount());
+}
+
+int AccessibleInstanceView::indexOfChild(
+ const QAccessibleInterface* iface) const
+{
+ if (!view()->model())
+ return -1;
+ QAccessibleInterface* parent = iface->parent();
+ if (parent->object() != view())
+ return -1;
+
+ Q_ASSERT(iface->role() !=
+ QAccessible::TreeItem); // should be handled by tree class
+ if (iface->role() == QAccessible::Cell ||
+ iface->role() == QAccessible::ListItem) {
+ const AccessibleInstanceViewItem* cell =
+ static_cast<const AccessibleInstanceViewItem*>(iface);
+ return logicalIndex(cell->m_index);
+ } else if (iface->role() == QAccessible::Pane) {
+ return 0; // corner button
+ } else {
+ qWarning() << "AccessibleInstanceView::indexOfChild has a child with "
+ "unknown role..."
+ << iface->role() << iface->text(QAccessible::Name);
+ }
+ // FIXME: we are in denial of our children. this should stop.
+ return -1;
+}
+
+QString AccessibleInstanceView::text(QAccessible::Text t) const
+{
+ if (t == QAccessible::Description)
+ return view()->accessibleDescription();
+ return view()->accessibleName();
+}
+
+QRect AccessibleInstanceView::rect() const
+{
+ if (!view()->isVisible())
+ return QRect();
+ QPoint pos = view()->mapToGlobal(QPoint(0, 0));
+ return QRect(pos.x(), pos.y(), view()->width(), view()->height());
+}
+
+QAccessibleInterface* AccessibleInstanceView::parent() const
+{
+ if (view() && view()->parent()) {
+ if (qstrcmp("QComboBoxPrivateContainer",
+ view()->parent()->metaObject()->className()) == 0) {
+ return QAccessible::queryAccessibleInterface(
+ view()->parent()->parent());
+ }
+ return QAccessible::queryAccessibleInterface(view()->parent());
+ }
+ return 0;
+}
+
+QAccessibleInterface* AccessibleInstanceView::child(int logicalIndex) const
+{
+ if (!view()->model())
+ return 0;
+
+ auto id = childToId.constFind(logicalIndex);
+ if (id != childToId.constEnd())
+ return QAccessible::accessibleInterface(id.value());
+
+ int columns = view()->model()->columnCount();
+
+ int row = logicalIndex / columns;
+ int column = logicalIndex % columns;
+
+ QAccessibleInterface* iface = 0;
+
+ QModelIndex index =
+ view()->model()->index(row, column, view()->rootIndex());
+ if (Q_UNLIKELY(!index.isValid())) {
+ qWarning("AccessibleInstanceView::child: Invalid index at: %d %d", row,
+ column);
+ return 0;
+ }
+ iface = new AccessibleInstanceViewItem(view(), index);
+
+ QAccessible::registerAccessibleInterface(iface);
+ childToId.insert(logicalIndex, QAccessible::uniqueId(iface));
+ return iface;
+}
+
+void* AccessibleInstanceView::interface_cast(QAccessible::InterfaceType t)
+{
+ if (t == QAccessible::TableInterface)
+ return static_cast<QAccessibleTableInterface*>(this);
+ return 0;
+}
+
+void AccessibleInstanceView::modelChange(
+ QAccessibleTableModelChangeEvent* event)
+{
+ // if there is no cache yet, we don't update anything
+ if (childToId.isEmpty())
+ return;
+
+ switch (event->modelChangeType()) {
+ case QAccessibleTableModelChangeEvent::ModelReset:
+ for (QAccessible::Id id : childToId)
+ QAccessible::deleteAccessibleInterface(id);
+ childToId.clear();
+ break;
+
+ // rows are inserted: move every row after that
+ case QAccessibleTableModelChangeEvent::RowsInserted:
+ case QAccessibleTableModelChangeEvent::ColumnsInserted: {
+
+ ChildCache newCache;
+ ChildCache::ConstIterator iter = childToId.constBegin();
+
+ while (iter != childToId.constEnd()) {
+ QAccessible::Id id = iter.value();
+ QAccessibleInterface* iface =
+ QAccessible::accessibleInterface(id);
+ Q_ASSERT(iface);
+ if (indexOfChild(iface) >= 0) {
+ newCache.insert(indexOfChild(iface), id);
+ } else {
+ // ### This should really not happen,
+ // but it might if the view has a root index set.
+ // This needs to be fixed.
+ QAccessible::deleteAccessibleInterface(id);
+ }
+ ++iter;
+ }
+ childToId = newCache;
+ break;
+ }
+
+ case QAccessibleTableModelChangeEvent::ColumnsRemoved:
+ case QAccessibleTableModelChangeEvent::RowsRemoved: {
+ ChildCache newCache;
+ ChildCache::ConstIterator iter = childToId.constBegin();
+ while (iter != childToId.constEnd()) {
+ QAccessible::Id id = iter.value();
+ QAccessibleInterface* iface =
+ QAccessible::accessibleInterface(id);
+ Q_ASSERT(iface);
+ if (iface->role() == QAccessible::Cell ||
+ iface->role() == QAccessible::ListItem) {
+ Q_ASSERT(iface->tableCellInterface());
+ AccessibleInstanceViewItem* cell =
+ static_cast<AccessibleInstanceViewItem*>(
+ iface->tableCellInterface());
+ // Since it is a QPersistentModelIndex, we only need to
+ // check if it is valid
+ if (cell->m_index.isValid())
+ newCache.insert(indexOfChild(cell), id);
+ else
+ QAccessible::deleteAccessibleInterface(id);
+ }
+ ++iter;
+ }
+ childToId = newCache;
+ break;
+ }
+
+ case QAccessibleTableModelChangeEvent::DataChanged:
+ // nothing to do in this case
+ break;
+ }
+}
+
+// TABLE CELL
+
+AccessibleInstanceViewItem::AccessibleInstanceViewItem(
+ QAbstractItemView* view_, const QModelIndex& index_)
+ : view(view_), m_index(index_)
+{
+ if (Q_UNLIKELY(!index_.isValid()))
+ qWarning() << "AccessibleInstanceViewItem::AccessibleInstanceViewItem "
+ "with invalid index: "
+ << index_;
+}
+
+void* AccessibleInstanceViewItem::interface_cast(QAccessible::InterfaceType t)
+{
+ if (t == QAccessible::TableCellInterface)
+ return static_cast<QAccessibleTableCellInterface*>(this);
+ if (t == QAccessible::ActionInterface)
+ return static_cast<QAccessibleActionInterface*>(this);
+ return 0;
+}
+
+int AccessibleInstanceViewItem::columnExtent() const
+{
+ return 1;
+}
+int AccessibleInstanceViewItem::rowExtent() const
+{
+ return 1;
+}
+
+QList<QAccessibleInterface*> AccessibleInstanceViewItem::rowHeaderCells() const
+{
+ return {};
+}
+
+QList<QAccessibleInterface*>
+AccessibleInstanceViewItem::columnHeaderCells() const
+{
+ return {};
+}
+
+int AccessibleInstanceViewItem::columnIndex() const
+{
+ if (!isValid()) {
+ return -1;
+ }
+
+ return m_index.column();
+}
+
+int AccessibleInstanceViewItem::rowIndex() const
+{
+ if (!isValid()) {
+ return -1;
+ }
+
+ return m_index.row();
+}
+
+bool AccessibleInstanceViewItem::isSelected() const
+{
+ if (!isValid()) {
+ return false;
+ }
+
+ return view->selectionModel()->isSelected(m_index);
+}
+
+QStringList AccessibleInstanceViewItem::actionNames() const
+{
+ QStringList names;
+ names << toggleAction();
+ return names;
+}
+
+void AccessibleInstanceViewItem::doAction(const QString& actionName)
+{
+ if (actionName == toggleAction()) {
+ if (isSelected()) {
+ unselectCell();
+ } else {
+ selectCell();
+ }
+ }
+}
+
+QStringList
+AccessibleInstanceViewItem::keyBindingsForAction(const QString&) const
+{
+ return QStringList();
+}
+
+void AccessibleInstanceViewItem::selectCell()
+{
+ if (!isValid()) {
+ return;
+ }
+ QAbstractItemView::SelectionMode selectionMode = view->selectionMode();
+ if (selectionMode == QAbstractItemView::NoSelection) {
+ return;
+ }
+
+ Q_ASSERT(table());
+ QAccessibleTableInterface* cellTable = table()->tableInterface();
+
+ switch (view->selectionBehavior()) {
+ case QAbstractItemView::SelectItems:
+ break;
+ case QAbstractItemView::SelectColumns:
+ if (cellTable)
+ cellTable->selectColumn(m_index.column());
+ return;
+ case QAbstractItemView::SelectRows:
+ if (cellTable)
+ cellTable->selectRow(m_index.row());
+ return;
+ }
+
+ if (selectionMode == QAbstractItemView::SingleSelection) {
+ view->clearSelection();
+ }
+
+ view->selectionModel()->select(m_index, QItemSelectionModel::Select);
+}
+
+void AccessibleInstanceViewItem::unselectCell()
+{
+ if (!isValid())
+ return;
+ QAbstractItemView::SelectionMode selectionMode = view->selectionMode();
+ if (selectionMode == QAbstractItemView::NoSelection)
+ return;
+
+ QAccessibleTableInterface* cellTable = table()->tableInterface();
+
+ switch (view->selectionBehavior()) {
+ case QAbstractItemView::SelectItems:
+ break;
+ case QAbstractItemView::SelectColumns:
+ if (cellTable)
+ cellTable->unselectColumn(m_index.column());
+ return;
+ case QAbstractItemView::SelectRows:
+ if (cellTable)
+ cellTable->unselectRow(m_index.row());
+ return;
+ }
+
+ // If the mode is not MultiSelection or ExtendedSelection and only
+ // one cell is selected it cannot be unselected by the user
+ if ((selectionMode != QAbstractItemView::MultiSelection) &&
+ (selectionMode != QAbstractItemView::ExtendedSelection) &&
+ (view->selectionModel()->selectedIndexes().count() <= 1))
+ return;
+
+ view->selectionModel()->select(m_index, QItemSelectionModel::Deselect);
+}
+
+QAccessibleInterface* AccessibleInstanceViewItem::table() const
+{
+ return QAccessible::queryAccessibleInterface(view);
+}
+
+QAccessible::Role AccessibleInstanceViewItem::role() const
+{
+ return QAccessible::ListItem;
+}
+
+QAccessible::State AccessibleInstanceViewItem::state() const
+{
+ QAccessible::State st;
+ if (!isValid())
+ return st;
+
+ QRect globalRect = view->rect();
+ globalRect.translate(view->mapToGlobal(QPoint(0, 0)));
+ if (!globalRect.intersects(rect()))
+ st.invisible = true;
+
+ if (view->selectionModel()->isSelected(m_index))
+ st.selected = true;
+ if (view->selectionModel()->currentIndex() == m_index)
+ st.focused = true;
+ if (m_index.model()->data(m_index, Qt::CheckStateRole).toInt() ==
+ Qt::Checked)
+ st.checked = true;
+
+ Qt::ItemFlags flags = m_index.flags();
+ if (flags & Qt::ItemIsSelectable) {
+ st.selectable = true;
+ st.focusable = true;
+ if (view->selectionMode() == QAbstractItemView::MultiSelection)
+ st.multiSelectable = true;
+ if (view->selectionMode() == QAbstractItemView::ExtendedSelection)
+ st.extSelectable = true;
+ }
+ return st;
+}
+
+QRect AccessibleInstanceViewItem::rect() const
+{
+ QRect r;
+ if (!isValid())
+ return r;
+ r = view->visualRect(m_index);
+
+ if (!r.isNull()) {
+ r.translate(view->viewport()->mapTo(view, QPoint(0, 0)));
+ r.translate(view->mapToGlobal(QPoint(0, 0)));
+ }
+ return r;
+}
+
+QString AccessibleInstanceViewItem::text(QAccessible::Text t) const
+{
+ QString value;
+ if (!isValid())
+ return value;
+ QAbstractItemModel* model = view->model();
+ switch (t) {
+ case QAccessible::Name:
+ value = model->data(m_index, Qt::AccessibleTextRole).toString();
+ if (value.isEmpty())
+ value = model->data(m_index, Qt::DisplayRole).toString();
+ break;
+ case QAccessible::Description:
+ value =
+ model->data(m_index, Qt::AccessibleDescriptionRole).toString();
+ break;
+ default:
+ break;
+ }
+ return value;
+}
+
+void AccessibleInstanceViewItem::setText(QAccessible::Text /*t*/,
+ const QString& text)
+{
+ if (!isValid() || !(m_index.flags() & Qt::ItemIsEditable))
+ return;
+ view->model()->setData(m_index, text);
+}
+
+bool AccessibleInstanceViewItem::isValid() const
+{
+ return view && view->model() && m_index.isValid();
+}
+
+QAccessibleInterface* AccessibleInstanceViewItem::parent() const
+{
+ return QAccessible::queryAccessibleInterface(view);
+}
+
+QAccessibleInterface* AccessibleInstanceViewItem::child(int) const
+{
+ return 0;
+}
+
+#endif /* !QT_NO_ACCESSIBILITY */
diff --git a/meshmc/launcher/ui/instanceview/AccessibleInstanceView.h b/meshmc/launcher/ui/instanceview/AccessibleInstanceView.h
new file mode 100644
index 0000000000..f6f2076f61
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/AccessibleInstanceView.h
@@ -0,0 +1,28 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QString>
+class QAccessibleInterface;
+
+QAccessibleInterface* groupViewAccessibleFactory(const QString& classname,
+ QObject* object);
diff --git a/meshmc/launcher/ui/instanceview/AccessibleInstanceView_p.h b/meshmc/launcher/ui/instanceview/AccessibleInstanceView_p.h
new file mode 100644
index 0000000000..3d47c88832
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/AccessibleInstanceView_p.h
@@ -0,0 +1,155 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "QtCore/qpointer.h"
+#include <QtGui/qaccessible.h>
+#include <QAccessibleWidget>
+#include <QAbstractItemView>
+#ifndef QT_NO_ACCESSIBILITY
+#include "InstanceView.h"
+// #include <QHeaderView>
+
+class QAccessibleTableCell;
+class QAccessibleTableHeaderCell;
+
+class AccessibleInstanceView : public QAccessibleTableInterface,
+ public QAccessibleObject
+{
+ public:
+ explicit AccessibleInstanceView(QWidget* w);
+ bool isValid() const override;
+
+ QAccessible::Role role() const override;
+ QAccessible::State state() const override;
+ QString text(QAccessible::Text t) const override;
+ QRect rect() const override;
+
+ QAccessibleInterface* childAt(int x, int y) const override;
+ int childCount() const override;
+ int indexOfChild(const QAccessibleInterface*) const override;
+
+ QAccessibleInterface* parent() const override;
+ QAccessibleInterface* child(int index) const override;
+
+ void* interface_cast(QAccessible::InterfaceType t) override;
+
+ // table interface
+ QAccessibleInterface* cellAt(int row, int column) const override;
+ QAccessibleInterface* caption() const override;
+ QAccessibleInterface* summary() const override;
+ QString columnDescription(int column) const override;
+ QString rowDescription(int row) const override;
+ int columnCount() const override;
+ int rowCount() const override;
+
+ // selection
+ int selectedCellCount() const override;
+ int selectedColumnCount() const override;
+ int selectedRowCount() const override;
+ QList<QAccessibleInterface*> selectedCells() const override;
+ QList<int> selectedColumns() const override;
+ QList<int> selectedRows() const override;
+ bool isColumnSelected(int column) const override;
+ bool isRowSelected(int row) const override;
+ bool selectRow(int row) override;
+ bool selectColumn(int column) override;
+ bool unselectRow(int row) override;
+ bool unselectColumn(int column) override;
+
+ QAbstractItemView* view() const;
+
+ void modelChange(QAccessibleTableModelChangeEvent* event) override;
+
+ protected:
+ // maybe vector
+ typedef QHash<int, QAccessible::Id> ChildCache;
+ mutable ChildCache childToId;
+
+ virtual ~AccessibleInstanceView();
+
+ private:
+ inline int logicalIndex(const QModelIndex& index) const;
+};
+
+class AccessibleInstanceViewItem : public QAccessibleInterface,
+ public QAccessibleTableCellInterface,
+ public QAccessibleActionInterface
+{
+ public:
+ AccessibleInstanceViewItem(QAbstractItemView* view,
+ const QModelIndex& m_index);
+
+ void* interface_cast(QAccessible::InterfaceType t) override;
+ QObject* object() const override
+ {
+ return nullptr;
+ }
+ QAccessible::Role role() const override;
+ QAccessible::State state() const override;
+ QRect rect() const override;
+ bool isValid() const override;
+
+ QAccessibleInterface* childAt(int, int) const override
+ {
+ return nullptr;
+ }
+ int childCount() const override
+ {
+ return 0;
+ }
+ int indexOfChild(const QAccessibleInterface*) const override
+ {
+ return -1;
+ }
+
+ QString text(QAccessible::Text t) const override;
+ void setText(QAccessible::Text t, const QString& text) override;
+
+ QAccessibleInterface* parent() const override;
+ QAccessibleInterface* child(int) const override;
+
+ // cell interface
+ int columnExtent() const override;
+ QList<QAccessibleInterface*> columnHeaderCells() const override;
+ int columnIndex() const override;
+ int rowExtent() const override;
+ QList<QAccessibleInterface*> rowHeaderCells() const override;
+ int rowIndex() const override;
+ bool isSelected() const override;
+ QAccessibleInterface* table() const override;
+
+ // action interface
+ QStringList actionNames() const override;
+ void doAction(const QString& actionName) override;
+ QStringList keyBindingsForAction(const QString& actionName) const override;
+
+ private:
+ QPointer<QAbstractItemView> view;
+ QPersistentModelIndex m_index;
+
+ void selectCell();
+ void unselectCell();
+
+ friend class AccessibleInstanceView;
+};
+#endif /* !QT_NO_ACCESSIBILITY */
diff --git a/meshmc/launcher/ui/instanceview/InstanceDelegate.cpp b/meshmc/launcher/ui/instanceview/InstanceDelegate.cpp
new file mode 100644
index 0000000000..c4c4f254d0
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/InstanceDelegate.cpp
@@ -0,0 +1,462 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "InstanceDelegate.h"
+#include <QPainter>
+#include <QTextOption>
+#include <QTextLayout>
+#include <QApplication>
+#include <QtMath>
+#include <QDebug>
+
+#include "InstanceView.h"
+#include "BaseInstance.h"
+#include "InstanceList.h"
+#include <xdgicon.h>
+#include <QTextEdit>
+
+// Origin: Qt
+static void viewItemTextLayout(QTextLayout& textLayout, int lineWidth,
+ qreal& height, qreal& widthUsed)
+{
+ height = 0;
+ widthUsed = 0;
+ textLayout.beginLayout();
+ QString str = textLayout.text();
+ while (true) {
+ QTextLine line = textLayout.createLine();
+ if (!line.isValid())
+ break;
+ if (line.textLength() == 0)
+ break;
+ line.setLineWidth(lineWidth);
+ line.setPosition(QPointF(0, height));
+ height += line.height();
+ widthUsed = qMax(widthUsed, line.naturalTextWidth());
+ }
+ textLayout.endLayout();
+}
+
+ListViewDelegate::ListViewDelegate(QObject* parent)
+ : QStyledItemDelegate(parent)
+{
+}
+
+void drawSelectionRect(QPainter* painter, const QStyleOptionViewItem& option,
+ const QRect& rect)
+{
+ if ((option.state & QStyle::State_Selected))
+ painter->fillRect(rect, option.palette.brush(QPalette::Highlight));
+ else {
+ QColor backgroundColor = option.palette.color(QPalette::Window);
+ backgroundColor.setAlpha(160);
+ painter->fillRect(rect, QBrush(backgroundColor));
+ }
+}
+
+void drawFocusRect(QPainter* painter, const QStyleOptionViewItem& option,
+ const QRect& rect)
+{
+ if (!(option.state & QStyle::State_HasFocus))
+ return;
+ QStyleOptionFocusRect opt;
+ opt.direction = option.direction;
+ opt.fontMetrics = option.fontMetrics;
+ opt.palette = option.palette;
+ opt.rect = rect;
+ // opt.state = option.state | QStyle::State_KeyboardFocusChange |
+ // QStyle::State_Item;
+ auto col = option.state & QStyle::State_Selected ? QPalette::Highlight
+ : QPalette::Base;
+ opt.backgroundColor = option.palette.color(col);
+ // Apparently some widget styles expect this hint to not be set
+ painter->setRenderHint(QPainter::Antialiasing, false);
+
+ QStyle* style =
+ option.widget ? option.widget->style() : QApplication::style();
+
+ style->drawPrimitive(QStyle::PE_FrameFocusRect, &opt, painter,
+ option.widget);
+
+ painter->setRenderHint(QPainter::Antialiasing);
+}
+
+// TODO this can be made a lot prettier
+void drawProgressOverlay(QPainter* painter, const QStyleOptionViewItem& option,
+ const int value, const int maximum)
+{
+ if (maximum == 0 || value == maximum) {
+ return;
+ }
+
+ painter->save();
+
+ qreal percent = (qreal)value / (qreal)maximum;
+ QColor color = option.palette.color(QPalette::Dark);
+ color.setAlphaF(0.70f);
+ painter->setBrush(color);
+ painter->setPen(QPen(QBrush(), 0));
+ painter->drawPie(option.rect, 90 * 16, -percent * 360 * 16);
+
+ painter->restore();
+}
+
+void drawBadges(QPainter* painter, const QStyleOptionViewItem& option,
+ BaseInstance* instance, QIcon::Mode mode, QIcon::State state)
+{
+ QList<QString> pixmaps;
+ if (instance->isRunning()) {
+ pixmaps.append("status-running");
+ } else if (instance->hasCrashed() || instance->hasVersionBroken()) {
+ pixmaps.append("status-bad");
+ }
+ if (instance->hasUpdateAvailable()) {
+ pixmaps.append("checkupdate");
+ }
+
+ static const int itemSide = 24;
+ static const int spacing = 1;
+ const int itemsPerRow =
+ qMax(1, qFloor(double(option.rect.width() + spacing) /
+ double(itemSide + spacing)));
+ const int rows = qCeil((double)pixmaps.size() / (double)itemsPerRow);
+ QListIterator<QString> it(pixmaps);
+ painter->translate(option.rect.topLeft());
+ for (int y = 0; y < rows; ++y) {
+ for (int x = 0; x < itemsPerRow; ++x) {
+ if (!it.hasNext()) {
+ return;
+ }
+ // FIXME: inject this.
+ auto icon = XdgIcon::fromTheme(it.next());
+ // opt.icon.paint(painter, iconbox, Qt::AlignCenter, mode, state);
+ const QPixmap pixmap;
+ // itemSide
+ QRect badgeRect(option.rect.width() - x * itemSide +
+ qMax(x - 1, 0) * spacing - itemSide,
+ y * itemSide + qMax(y - 1, 0) * spacing, itemSide,
+ itemSide);
+ icon.paint(painter, badgeRect, Qt::AlignCenter, mode, state);
+ }
+ }
+ painter->translate(-option.rect.topLeft());
+}
+
+static QSize viewItemTextSize(const QStyleOptionViewItem* option)
+{
+ QStyle* style =
+ option->widget ? option->widget->style() : QApplication::style();
+ QTextOption textOption;
+ textOption.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
+ QTextLayout textLayout;
+ textLayout.setTextOption(textOption);
+ textLayout.setFont(option->font);
+ textLayout.setText(option->text);
+ const int textMargin = style->pixelMetric(QStyle::PM_FocusFrameHMargin,
+ option, option->widget) +
+ 1;
+ QRect bounds(0, 0, 100 - 2 * textMargin, 600);
+ qreal height = 0, widthUsed = 0;
+ viewItemTextLayout(textLayout, bounds.width(), height, widthUsed);
+ const QSize size(qCeil(widthUsed), qCeil(height));
+ return QSize(size.width() + 2 * textMargin, size.height());
+}
+
+void ListViewDelegate::paint(QPainter* painter,
+ const QStyleOptionViewItem& option,
+ const QModelIndex& index) const
+{
+ QStyleOptionViewItem opt = option;
+ initStyleOption(&opt, index);
+ painter->save();
+ painter->setClipRect(opt.rect);
+
+ opt.features |= QStyleOptionViewItem::WrapText;
+ opt.text = index.data().toString();
+ opt.textElideMode = Qt::ElideRight;
+ opt.displayAlignment = Qt::AlignTop | Qt::AlignHCenter;
+
+ QStyle* style = opt.widget ? opt.widget->style() : QApplication::style();
+
+ // const int iconSize = style->pixelMetric(QStyle::PM_IconViewIconSize);
+ const int iconSize = 48;
+ QRect iconbox = opt.rect;
+ const int textMargin =
+ style->pixelMetric(QStyle::PM_FocusFrameHMargin, 0, opt.widget) + 1;
+ QRect textRect = opt.rect;
+ QRect textHighlightRect = textRect;
+ // clip the decoration on top, remove width padding
+ textRect.adjust(textMargin, iconSize + textMargin + 5, -textMargin, 0);
+
+ textHighlightRect.adjust(0, iconSize + 5, 0, 0);
+
+ // draw background
+ {
+ // FIXME: unused
+ // QSize textSize = viewItemTextSize ( &opt );
+ drawSelectionRect(painter, opt, textHighlightRect);
+ /*
+ QPalette::ColorGroup cg;
+ QStyleOptionViewItem opt2(opt);
+
+ if ((opt.widget && opt.widget->isEnabled()) || (opt.state &
+ QStyle::State_Enabled))
+ {
+ if (!(opt.state & QStyle::State_Active))
+ cg = QPalette::Inactive;
+ else
+ cg = QPalette::Normal;
+ }
+ else
+ {
+ cg = QPalette::Disabled;
+ }
+ */
+ /*
+ opt2.palette.setCurrentColorGroup(cg);
+
+ // fill in background, if any
+
+
+ if (opt.backgroundBrush.style() != Qt::NoBrush)
+ {
+ QPointF oldBO = painter->brushOrigin();
+ painter->setBrushOrigin(opt.rect.topLeft());
+ painter->fillRect(opt.rect, opt.backgroundBrush);
+ painter->setBrushOrigin(oldBO);
+ }
+
+ drawSelectionRect(painter, opt2, textHighlightRect);
+ */
+
+ /*
+ if (opt.showDecorationSelected)
+ {
+ drawSelectionRect(painter, opt2, opt.rect);
+ drawFocusRect(painter, opt2, opt.rect);
+ // painter->fillRect ( opt.rect, opt.palette.brush ( cg,
+ QPalette::Highlight ) );
+ }
+ else
+ {
+
+ // if ( opt.state & QStyle::State_Selected )
+ {
+ // QRect textRect = subElementRect (
+ QStyle::SE_ItemViewItemText, opt,
+ // opt.widget );
+ // painter->fillRect ( textHighlightRect, opt.palette.brush (
+ cg,
+ // QPalette::Highlight ) );
+ drawSelectionRect(painter, opt2, textHighlightRect);
+ drawFocusRect(painter, opt2, textHighlightRect);
+ }
+ }
+ */
+ }
+
+ // icon mode and state, also used for badges
+ QIcon::Mode mode = QIcon::Normal;
+ if (!(opt.state & QStyle::State_Enabled))
+ mode = QIcon::Disabled;
+ else if (opt.state & QStyle::State_Selected)
+ mode = QIcon::Selected;
+ QIcon::State state =
+ opt.state & QStyle::State_Open ? QIcon::On : QIcon::Off;
+
+ // draw the icon
+ {
+ iconbox.setHeight(iconSize);
+ opt.icon.paint(painter, iconbox, Qt::AlignCenter, mode, state);
+ }
+ // set the text colors
+ QPalette::ColorGroup cg = opt.state & QStyle::State_Enabled
+ ? QPalette::Normal
+ : QPalette::Disabled;
+ if (cg == QPalette::Normal && !(opt.state & QStyle::State_Active))
+ cg = QPalette::Inactive;
+ if (opt.state & QStyle::State_Selected) {
+ painter->setPen(opt.palette.color(cg, QPalette::HighlightedText));
+ } else {
+ painter->setPen(opt.palette.color(cg, QPalette::Text));
+ }
+
+ // draw the text
+ QTextOption textOption;
+ textOption.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
+ textOption.setTextDirection(opt.direction);
+ textOption.setAlignment(
+ QStyle::visualAlignment(opt.direction, opt.displayAlignment));
+ QTextLayout textLayout;
+ textLayout.setTextOption(textOption);
+ textLayout.setFont(opt.font);
+ textLayout.setText(opt.text);
+
+ qreal width, height;
+ viewItemTextLayout(textLayout, textRect.width(), height, width);
+
+ const int lineCount = textLayout.lineCount();
+
+ const QRect layoutRect =
+ QStyle::alignedRect(opt.direction, opt.displayAlignment,
+ QSize(textRect.width(), int(height)), textRect);
+ const QPointF position = layoutRect.topLeft();
+ for (int i = 0; i < lineCount; ++i) {
+ const QTextLine line = textLayout.lineAt(i);
+ line.draw(painter, position);
+ }
+
+ // FIXME: this really has no business of being here. Make generic.
+ auto instance = (BaseInstance*)index.data(InstanceList::InstancePointerRole)
+ .value<void*>();
+ if (instance) {
+ drawBadges(painter, opt, instance, mode, state);
+ }
+
+ drawProgressOverlay(
+ painter, opt, index.data(InstanceViewRoles::ProgressValueRole).toInt(),
+ index.data(InstanceViewRoles::ProgressMaximumRole).toInt());
+
+ painter->restore();
+}
+
+QSize ListViewDelegate::sizeHint(const QStyleOptionViewItem& option,
+ const QModelIndex& index) const
+{
+ QStyleOptionViewItem opt = option;
+ initStyleOption(&opt, index);
+ opt.features |= QStyleOptionViewItem::WrapText;
+ opt.text = index.data().toString();
+ opt.textElideMode = Qt::ElideRight;
+ opt.displayAlignment = Qt::AlignTop | Qt::AlignHCenter;
+
+ QStyle* style = opt.widget ? opt.widget->style() : QApplication::style();
+ const int textMargin =
+ style->pixelMetric(QStyle::PM_FocusFrameHMargin, &option, opt.widget) +
+ 1;
+ int height = 48 + textMargin * 2 + 5; // TODO: turn constants into variables
+ QSize szz = viewItemTextSize(&opt);
+ height += szz.height();
+ // FIXME: maybe the icon items could scale and keep proportions?
+ QSize sz(100, height);
+ return sz;
+}
+
+class NoReturnTextEdit : public QTextEdit
+{
+ Q_OBJECT
+ public:
+ explicit NoReturnTextEdit(QWidget* parent) : QTextEdit(parent)
+ {
+ setTextInteractionFlags(Qt::TextEditorInteraction);
+ setHorizontalScrollBarPolicy(Qt::ScrollBarPolicy::ScrollBarAlwaysOff);
+ setVerticalScrollBarPolicy(Qt::ScrollBarPolicy::ScrollBarAlwaysOff);
+ }
+ bool event(QEvent* event) override
+ {
+ auto eventType = event->type();
+ if (eventType == QEvent::KeyPress || eventType == QEvent::KeyRelease) {
+ QKeyEvent* keyEvent = static_cast<QKeyEvent*>(event);
+ auto key = keyEvent->key();
+ if (key == Qt::Key_Return || key == Qt::Key_Enter) {
+ emit editingDone();
+ return true;
+ }
+ if (key == Qt::Key_Tab) {
+ return true;
+ }
+ }
+ return QTextEdit::event(event);
+ }
+ signals:
+ void editingDone();
+};
+
+void ListViewDelegate::updateEditorGeometry(QWidget* editor,
+ const QStyleOptionViewItem& option,
+ const QModelIndex& index) const
+{
+ const int iconSize = 48;
+ QRect textRect = option.rect;
+ // QStyle *style = option.widget ? option.widget->style() :
+ // QApplication::style();
+ textRect.adjust(0, iconSize + 5, 0, 0);
+ editor->setGeometry(textRect);
+}
+
+void ListViewDelegate::setEditorData(QWidget* editor,
+ const QModelIndex& index) const
+{
+ auto text = index.data(Qt::EditRole).toString();
+ QTextEdit* realeditor = qobject_cast<NoReturnTextEdit*>(editor);
+ realeditor->setAlignment(Qt::AlignHCenter | Qt::AlignTop);
+ realeditor->append(text);
+ realeditor->selectAll();
+ realeditor->document()->clearUndoRedoStacks();
+}
+
+void ListViewDelegate::setModelData(QWidget* editor, QAbstractItemModel* model,
+ const QModelIndex& index) const
+{
+ QTextEdit* realeditor = qobject_cast<NoReturnTextEdit*>(editor);
+ QString text = realeditor->toPlainText();
+ text.replace(QChar('\n'), QChar(' '));
+ text = text.trimmed();
+ if (text.size() != 0) {
+ model->setData(index, text);
+ }
+}
+
+QWidget* ListViewDelegate::createEditor(QWidget* parent,
+ const QStyleOptionViewItem& option,
+ const QModelIndex& index) const
+{
+ auto editor = new NoReturnTextEdit(parent);
+ connect(editor, &NoReturnTextEdit::editingDone, this,
+ &ListViewDelegate::editingDone);
+ return editor;
+}
+
+void ListViewDelegate::editingDone()
+{
+ NoReturnTextEdit* editor = qobject_cast<NoReturnTextEdit*>(sender());
+ emit commitData(editor);
+ emit closeEditor(editor);
+}
+
+#include "InstanceDelegate.moc"
diff --git a/meshmc/launcher/ui/instanceview/InstanceDelegate.h b/meshmc/launcher/ui/instanceview/InstanceDelegate.h
new file mode 100644
index 0000000000..36df302aca
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/InstanceDelegate.h
@@ -0,0 +1,69 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include <QStyledItemDelegate>
+#include <QCache>
+
+class ListViewDelegate : public QStyledItemDelegate
+{
+ Q_OBJECT
+
+ public:
+ explicit ListViewDelegate(QObject* parent = 0);
+ virtual ~ListViewDelegate() {}
+
+ void paint(QPainter* painter, const QStyleOptionViewItem& option,
+ const QModelIndex& index) const override;
+ QSize sizeHint(const QStyleOptionViewItem& option,
+ const QModelIndex& index) const override;
+ void updateEditorGeometry(QWidget* editor,
+ const QStyleOptionViewItem& option,
+ const QModelIndex& index) const override;
+ QWidget* createEditor(QWidget* parent, const QStyleOptionViewItem& option,
+ const QModelIndex& index) const override;
+
+ void setEditorData(QWidget* editor,
+ const QModelIndex& index) const override;
+ void setModelData(QWidget* editor, QAbstractItemModel* model,
+ const QModelIndex& index) const override;
+
+ private slots:
+ void editingDone();
+};
diff --git a/meshmc/launcher/ui/instanceview/InstanceProxyModel.cpp b/meshmc/launcher/ui/instanceview/InstanceProxyModel.cpp
new file mode 100644
index 0000000000..93de0231a1
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/InstanceProxyModel.cpp
@@ -0,0 +1,100 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "InstanceProxyModel.h"
+
+#include "InstanceView.h"
+#include "Application.h"
+#include <BaseInstance.h>
+#include <icons/IconList.h>
+
+#include <QDebug>
+
+InstanceProxyModel::InstanceProxyModel(QObject* parent)
+ : QSortFilterProxyModel(parent)
+{
+ m_naturalSort.setNumericMode(true);
+ m_naturalSort.setCaseSensitivity(Qt::CaseSensitivity::CaseInsensitive);
+ // FIXME: use loaded translation as source of locale instead, hook this up
+ // to translation changes
+ m_naturalSort.setLocale(QLocale::system());
+}
+
+QVariant InstanceProxyModel::data(const QModelIndex& index, int role) const
+{
+ QVariant data = QSortFilterProxyModel::data(index, role);
+ if (role == Qt::DecorationRole) {
+ return QVariant(APPLICATION->icons()->getIcon(data.toString()));
+ }
+ return data;
+}
+
+bool InstanceProxyModel::lessThan(const QModelIndex& left,
+ const QModelIndex& right) const
+{
+ const QString leftCategory =
+ left.data(InstanceViewRoles::GroupRole).toString();
+ const QString rightCategory =
+ right.data(InstanceViewRoles::GroupRole).toString();
+ if (leftCategory == rightCategory) {
+ return subSortLessThan(left, right);
+ } else {
+ // FIXME: real group sorting happens in
+ // InstanceView::updateGeometries(), see LocaleString
+ auto result = leftCategory.localeAwareCompare(rightCategory);
+ if (result == 0) {
+ return subSortLessThan(left, right);
+ }
+ return result < 0;
+ }
+}
+
+bool InstanceProxyModel::subSortLessThan(const QModelIndex& left,
+ const QModelIndex& right) const
+{
+ BaseInstance* pdataLeft =
+ static_cast<BaseInstance*>(left.internalPointer());
+ BaseInstance* pdataRight =
+ static_cast<BaseInstance*>(right.internalPointer());
+ QString sortMode = APPLICATION->settings()->get("InstSortMode").toString();
+ if (sortMode == "LastLaunch") {
+ return pdataLeft->lastLaunch() > pdataRight->lastLaunch();
+ } else {
+ return m_naturalSort.compare(pdataLeft->name(), pdataRight->name()) < 0;
+ }
+}
diff --git a/meshmc/launcher/ui/instanceview/InstanceProxyModel.h b/meshmc/launcher/ui/instanceview/InstanceProxyModel.h
new file mode 100644
index 0000000000..898f01b57d
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/InstanceProxyModel.h
@@ -0,0 +1,60 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include <QSortFilterProxyModel>
+#include <QCollator>
+
+class InstanceProxyModel : public QSortFilterProxyModel
+{
+ Q_OBJECT
+
+ public:
+ InstanceProxyModel(QObject* parent = 0);
+
+ protected:
+ QVariant data(const QModelIndex& index, int role) const override;
+ bool lessThan(const QModelIndex& left,
+ const QModelIndex& right) const override;
+ bool subSortLessThan(const QModelIndex& left,
+ const QModelIndex& right) const;
+
+ private:
+ QCollator m_naturalSort;
+};
diff --git a/meshmc/launcher/ui/instanceview/InstanceView.cpp b/meshmc/launcher/ui/instanceview/InstanceView.cpp
new file mode 100644
index 0000000000..df5e772e1f
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/InstanceView.cpp
@@ -0,0 +1,952 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "InstanceView.h"
+
+#include <QPainter>
+#include <QApplication>
+#include <QtMath>
+#include <QMouseEvent>
+#include <QListView>
+#include <QPersistentModelIndex>
+#include <QDrag>
+#include <QMimeData>
+#include <QCache>
+#include <QScrollBar>
+#include <QAccessible>
+
+#include "VisualGroup.h"
+#include <QDebug>
+
+#include <Application.h>
+#include <InstanceList.h>
+
+template <typename T> bool listsIntersect(const QList<T>& l1, const QList<T> t2)
+{
+ for (auto& item : l1) {
+ if (t2.contains(item)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+InstanceView::InstanceView(QWidget* parent) : QAbstractItemView(parent)
+{
+ setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+ setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
+ setAcceptDrops(true);
+ setAutoScroll(true);
+}
+
+InstanceView::~InstanceView()
+{
+ qDeleteAll(m_groups);
+ m_groups.clear();
+}
+
+void InstanceView::setModel(QAbstractItemModel* model)
+{
+ QAbstractItemView::setModel(model);
+ connect(model, &QAbstractItemModel::modelReset, this,
+ &InstanceView::modelReset);
+ connect(model, &QAbstractItemModel::rowsRemoved, this,
+ &InstanceView::rowsRemoved);
+}
+
+void InstanceView::dataChanged(const QModelIndex& topLeft,
+ const QModelIndex& bottomRight,
+ const QVector<int>& roles)
+{
+ scheduleDelayedItemsLayout();
+}
+void InstanceView::rowsInserted(const QModelIndex& parent, int start, int end)
+{
+ scheduleDelayedItemsLayout();
+}
+
+void InstanceView::rowsAboutToBeRemoved(const QModelIndex& parent, int start,
+ int end)
+{
+ scheduleDelayedItemsLayout();
+}
+
+void InstanceView::modelReset()
+{
+ scheduleDelayedItemsLayout();
+}
+
+void InstanceView::rowsRemoved()
+{
+ scheduleDelayedItemsLayout();
+}
+
+void InstanceView::currentChanged(const QModelIndex& current,
+ const QModelIndex& previous)
+{
+ QAbstractItemView::currentChanged(current, previous);
+ // TODO: for accessibility support, implement+register a factory, steal
+ // QAccessibleTable from Qt and return an instance of it for InstanceView.
+#ifndef QT_NO_ACCESSIBILITY
+ if (QAccessible::isActive() && current.isValid()) {
+ QAccessibleEvent event(this, QAccessible::Focus);
+ event.setChild(current.row());
+ QAccessible::updateAccessibility(&event);
+ }
+#endif /* !QT_NO_ACCESSIBILITY */
+}
+
+class LocaleString : public QString
+{
+ public:
+ LocaleString(const char* s) : QString(s) {}
+ LocaleString(const QString& s) : QString(s) {}
+};
+
+inline bool operator<(const LocaleString& lhs, const LocaleString& rhs)
+{
+ return (QString::localeAwareCompare(lhs, rhs) < 0);
+}
+
+void InstanceView::updateScrollbar()
+{
+ int previousScroll = verticalScrollBar()->value();
+ if (m_groups.isEmpty()) {
+ verticalScrollBar()->setRange(0, 0);
+ } else {
+ int totalHeight = 0;
+ // top margin
+ totalHeight += m_categoryMargin;
+ int itemScroll = 0;
+ for (auto category : m_groups) {
+ category->m_verticalPosition = totalHeight;
+ totalHeight += category->totalHeight() + m_categoryMargin;
+ if (!itemScroll && category->totalHeight() != 0) {
+ itemScroll = category->contentHeight() / category->numRows();
+ }
+ }
+ // do not divide by zero
+ if (itemScroll == 0)
+ itemScroll = 64;
+
+ totalHeight += m_bottomMargin;
+ verticalScrollBar()->setSingleStep(itemScroll);
+ const int rowsPerPage = qMax(viewport()->height() / itemScroll, 1);
+ verticalScrollBar()->setPageStep(rowsPerPage * itemScroll);
+
+ verticalScrollBar()->setRange(0, totalHeight - height());
+ }
+
+ verticalScrollBar()->setValue(
+ qMin(previousScroll, verticalScrollBar()->maximum()));
+}
+
+void InstanceView::updateGeometries()
+{
+ geometryCache.clear();
+
+ QMap<LocaleString, VisualGroup*> cats;
+
+ for (int i = 0; i < model()->rowCount(); ++i) {
+ const QString groupName =
+ model()->index(i, 0).data(InstanceViewRoles::GroupRole).toString();
+ if (!cats.contains(groupName)) {
+ VisualGroup* old = this->category(groupName);
+ if (old) {
+ auto cat = new VisualGroup(old);
+ cats.insert(groupName, cat);
+ cat->update();
+ } else {
+ auto cat = new VisualGroup(groupName, this);
+ if (fVisibility) {
+ cat->collapsed = fVisibility(groupName);
+ }
+ cats.insert(groupName, cat);
+ cat->update();
+ }
+ }
+ }
+
+ qDeleteAll(m_groups);
+ m_groups = cats.values();
+ updateScrollbar();
+ viewport()->update();
+}
+
+bool InstanceView::isIndexHidden(const QModelIndex& index) const
+{
+ VisualGroup* cat = category(index);
+ if (cat) {
+ return cat->collapsed;
+ } else {
+ return false;
+ }
+}
+
+VisualGroup* InstanceView::category(const QModelIndex& index) const
+{
+ return category(index.data(InstanceViewRoles::GroupRole).toString());
+}
+
+VisualGroup* InstanceView::category(const QString& cat) const
+{
+ for (auto group : m_groups) {
+ if (group->text == cat) {
+ return group;
+ }
+ }
+ return nullptr;
+}
+
+VisualGroup* InstanceView::categoryAt(const QPoint& pos,
+ VisualGroup::HitResults& result) const
+{
+ for (auto group : m_groups) {
+ result = group->hitScan(pos);
+ if (result != VisualGroup::NoHit) {
+ return group;
+ }
+ }
+ result = VisualGroup::NoHit;
+ return nullptr;
+}
+
+QString InstanceView::groupNameAt(const QPoint& point)
+{
+ executeDelayedItemsLayout();
+
+ VisualGroup::HitResults hitresult;
+ auto group = categoryAt(point + offset(), hitresult);
+ if (group &&
+ (hitresult & (VisualGroup::HeaderHit | VisualGroup::BodyHit))) {
+ return group->text;
+ }
+ return QString();
+}
+
+int InstanceView::calculateItemsPerRow() const
+{
+ return qFloor((qreal)(contentWidth()) / (qreal)(itemWidth() + m_spacing));
+}
+
+int InstanceView::contentWidth() const
+{
+ return width() - m_leftMargin - m_rightMargin;
+}
+
+int InstanceView::itemWidth() const
+{
+ return m_itemWidth;
+}
+
+void InstanceView::mousePressEvent(QMouseEvent* event)
+{
+ executeDelayedItemsLayout();
+
+ QPoint visualPos = event->pos();
+ QPoint geometryPos = event->pos() + offset();
+
+ QPersistentModelIndex index = indexAt(visualPos);
+
+ m_pressedIndex = index;
+ m_pressedAlreadySelected = selectionModel()->isSelected(m_pressedIndex);
+ m_pressedPosition = geometryPos;
+
+ VisualGroup::HitResults hitresult;
+ m_pressedCategory = categoryAt(geometryPos, hitresult);
+ if (m_pressedCategory && hitresult & VisualGroup::CheckboxHit) {
+ setState(m_pressedCategory->collapsed ? ExpandingState
+ : CollapsingState);
+ event->accept();
+ return;
+ }
+
+ if (index.isValid() && (index.flags() & Qt::ItemIsEnabled)) {
+ if (index != currentIndex()) {
+ // FIXME: better!
+ m_currentCursorColumn = -1;
+ }
+ // we disable scrollTo for mouse press so the item doesn't change
+ // position when the user is interacting with it (ie. clicking on it)
+ bool autoScroll = hasAutoScroll();
+ setAutoScroll(false);
+ selectionModel()->setCurrentIndex(index, QItemSelectionModel::NoUpdate);
+
+ setAutoScroll(autoScroll);
+ QRect rect(visualPos, visualPos);
+ setSelection(rect, QItemSelectionModel::ClearAndSelect);
+
+ // signal handlers may change the model
+ emit pressed(index);
+ } else {
+ // Forces a finalize() even if mouse is pressed, but not on a item
+ selectionModel()->select(QModelIndex(), QItemSelectionModel::Select);
+ }
+}
+
+void InstanceView::mouseMoveEvent(QMouseEvent* event)
+{
+ executeDelayedItemsLayout();
+
+ QPoint topLeft;
+ QPoint visualPos = event->pos();
+ QPoint geometryPos = event->pos() + offset();
+
+ if (state() == ExpandingState || state() == CollapsingState) {
+ return;
+ }
+
+ if (state() == DraggingState) {
+ topLeft = m_pressedPosition - offset();
+ if ((topLeft - event->pos()).manhattanLength() >
+ QApplication::startDragDistance()) {
+ m_pressedIndex = QModelIndex();
+ startDrag(model()->supportedDragActions());
+ setState(NoState);
+ stopAutoScroll();
+ }
+ return;
+ }
+
+ if (selectionMode() != SingleSelection) {
+ topLeft = m_pressedPosition - offset();
+ } else {
+ topLeft = geometryPos;
+ }
+
+ if (m_pressedIndex.isValid() && (state() != DragSelectingState) &&
+ (event->buttons() != Qt::NoButton) && !selectedIndexes().isEmpty()) {
+ setState(DraggingState);
+ return;
+ }
+
+ if ((event->buttons() & Qt::LeftButton) && selectionModel()) {
+ setState(DragSelectingState);
+
+ setSelection(QRect(visualPos, visualPos),
+ QItemSelectionModel::ClearAndSelect);
+ QModelIndex index = indexAt(visualPos);
+
+ // set at the end because it might scroll the view
+ if (index.isValid() && (index != selectionModel()->currentIndex()) &&
+ (index.flags() & Qt::ItemIsEnabled)) {
+ selectionModel()->setCurrentIndex(index,
+ QItemSelectionModel::NoUpdate);
+ }
+ }
+}
+
+void InstanceView::mouseReleaseEvent(QMouseEvent* event)
+{
+ executeDelayedItemsLayout();
+
+ QPoint visualPos = event->pos();
+ QPoint geometryPos = event->pos() + offset();
+ QPersistentModelIndex index = indexAt(visualPos);
+
+ VisualGroup::HitResults hitresult;
+
+ bool click = (index == m_pressedIndex && index.isValid()) ||
+ (m_pressedCategory &&
+ m_pressedCategory == categoryAt(geometryPos, hitresult));
+
+ if (click && m_pressedCategory) {
+ if (state() == ExpandingState) {
+ m_pressedCategory->collapsed = false;
+ emit groupStateChanged(m_pressedCategory->text, false);
+
+ updateGeometries();
+ viewport()->update();
+ event->accept();
+ m_pressedCategory = nullptr;
+ setState(NoState);
+ return;
+ } else if (state() == CollapsingState) {
+ m_pressedCategory->collapsed = true;
+ emit groupStateChanged(m_pressedCategory->text, true);
+
+ updateGeometries();
+ viewport()->update();
+ event->accept();
+ m_pressedCategory = nullptr;
+ setState(NoState);
+ return;
+ }
+ }
+
+ m_ctrlDragSelectionFlag = QItemSelectionModel::NoUpdate;
+
+ setState(NoState);
+
+ if (click) {
+ if (event->button() == Qt::LeftButton) {
+ emit clicked(index);
+ }
+ QStyleOptionViewItem option = viewOptions();
+ if (m_pressedAlreadySelected) {
+ option.state |= QStyle::State_Selected;
+ }
+ if ((model()->flags(index) & Qt::ItemIsEnabled) &&
+ style()->styleHint(QStyle::SH_ItemView_ActivateItemOnSingleClick,
+ &option, this)) {
+ emit activated(index);
+ }
+ }
+}
+
+void InstanceView::mouseDoubleClickEvent(QMouseEvent* event)
+{
+ executeDelayedItemsLayout();
+
+ QModelIndex index = indexAt(event->pos());
+ if (!index.isValid() || !(index.flags() & Qt::ItemIsEnabled) ||
+ (m_pressedIndex != index)) {
+ QMouseEvent me(QEvent::MouseButtonPress, event->localPos(),
+ event->windowPos(), event->screenPos(), event->button(),
+ event->buttons(), event->modifiers());
+ mousePressEvent(&me);
+ return;
+ }
+ // signal handlers may change the model
+ QPersistentModelIndex persistent = index;
+ emit doubleClicked(persistent);
+
+ QStyleOptionViewItem option = viewOptions();
+ if ((model()->flags(index) & Qt::ItemIsEnabled) &&
+ !style()->styleHint(QStyle::SH_ItemView_ActivateItemOnSingleClick,
+ &option, this)) {
+ emit activated(index);
+ }
+}
+
+void InstanceView::paintEvent(QPaintEvent* event)
+{
+ executeDelayedItemsLayout();
+
+ QPainter painter(this->viewport());
+
+ QStyleOptionViewItem option(viewOptions());
+ option.widget = this;
+
+ int wpWidth = viewport()->width();
+ option.rect.setWidth(wpWidth);
+ for (int i = 0; i < m_groups.size(); ++i) {
+ VisualGroup* category = m_groups.at(i);
+ int y = category->verticalPosition();
+ y -= verticalOffset();
+ QRect backup = option.rect;
+ int height = category->totalHeight();
+ option.rect.setTop(y);
+ option.rect.setHeight(height);
+ option.rect.setLeft(m_leftMargin);
+ option.rect.setRight(wpWidth - m_rightMargin);
+ category->drawHeader(&painter, option);
+ y += category->totalHeight() + m_categoryMargin;
+ option.rect = backup;
+ }
+
+ for (int i = 0; i < model()->rowCount(); ++i) {
+ const QModelIndex index = model()->index(i, 0);
+ if (isIndexHidden(index)) {
+ continue;
+ }
+ Qt::ItemFlags flags = index.flags();
+ option.rect = visualRect(index);
+ option.features |= QStyleOptionViewItem::WrapText;
+ if (flags & Qt::ItemIsSelectable &&
+ selectionModel()->isSelected(index)) {
+ option.state |= selectionModel()->isSelected(index)
+ ? QStyle::State_Selected
+ : QStyle::State_None;
+ } else {
+ option.state &= ~QStyle::State_Selected;
+ }
+ option.state |= (index == currentIndex()) ? QStyle::State_HasFocus
+ : QStyle::State_None;
+ if (!(flags & Qt::ItemIsEnabled)) {
+ option.state &= ~QStyle::State_Enabled;
+ }
+ itemDelegate()->paint(&painter, option, index);
+ }
+
+ /*
+ * Drop indicators for manual reordering...
+ */
+#if 0
+ if (!m_lastDragPosition.isNull())
+ {
+ QPair<Group *, int> pair = rowDropPos(m_lastDragPosition);
+ Group *category = pair.first;
+ int row = pair.second;
+ if (category)
+ {
+ int internalRow = row - category->firstItemIndex;
+ QLine line;
+ if (internalRow >= category->numItems())
+ {
+ QRect toTheRightOfRect = visualRect(category->lastItem());
+ line = QLine(toTheRightOfRect.topRight(), toTheRightOfRect.bottomRight());
+ }
+ else
+ {
+ QRect toTheLeftOfRect = visualRect(model()->index(row, 0));
+ line = QLine(toTheLeftOfRect.topLeft(), toTheLeftOfRect.bottomLeft());
+ }
+ painter.save();
+ painter.setPen(QPen(Qt::black, 3));
+ painter.drawLine(line);
+ painter.restore();
+ }
+ }
+#endif
+}
+
+void InstanceView::resizeEvent(QResizeEvent* event)
+{
+ int newItemsPerRow = calculateItemsPerRow();
+ if (newItemsPerRow != m_currentItemsPerRow) {
+ m_currentCursorColumn = -1;
+ m_currentItemsPerRow = newItemsPerRow;
+ updateGeometries();
+ } else {
+ updateScrollbar();
+ }
+}
+
+void InstanceView::dragEnterEvent(QDragEnterEvent* event)
+{
+ executeDelayedItemsLayout();
+
+ if (!isDragEventAccepted(event)) {
+ return;
+ }
+ m_lastDragPosition = event->pos() + offset();
+ viewport()->update();
+ event->accept();
+}
+
+void InstanceView::dragMoveEvent(QDragMoveEvent* event)
+{
+ executeDelayedItemsLayout();
+
+ if (!isDragEventAccepted(event)) {
+ return;
+ }
+ m_lastDragPosition = event->pos() + offset();
+ viewport()->update();
+ event->accept();
+}
+
+void InstanceView::dragLeaveEvent(QDragLeaveEvent* event)
+{
+ executeDelayedItemsLayout();
+
+ m_lastDragPosition = QPoint();
+ viewport()->update();
+}
+
+void InstanceView::dropEvent(QDropEvent* event)
+{
+ executeDelayedItemsLayout();
+
+ m_lastDragPosition = QPoint();
+
+ stopAutoScroll();
+ setState(NoState);
+
+ auto mimedata = event->mimeData();
+
+ if (event->source() == this) {
+ if (event->possibleActions() & Qt::MoveAction) {
+ QPair<VisualGroup*, VisualGroup::HitResults> dropPos =
+ rowDropPos(event->pos());
+ const VisualGroup* group = dropPos.first;
+ auto hitresult = dropPos.second;
+
+ if (hitresult == VisualGroup::HitResult::NoHit) {
+ viewport()->update();
+ return;
+ }
+ auto instanceId =
+ QString::fromUtf8(mimedata->data("application/x-instanceid"));
+ auto instanceList = APPLICATION->instances().get();
+ instanceList->setInstanceGroup(instanceId, group->text);
+ event->setDropAction(Qt::MoveAction);
+ event->accept();
+
+ updateGeometries();
+ viewport()->update();
+ }
+ return;
+ }
+
+ // check if the action is supported
+ if (!mimedata) {
+ return;
+ }
+
+ // files dropped from outside?
+ if (mimedata->hasUrls()) {
+ auto urls = mimedata->urls();
+ event->accept();
+ emit droppedURLs(urls);
+ }
+}
+
+void InstanceView::startDrag(Qt::DropActions supportedActions)
+{
+ executeDelayedItemsLayout();
+
+ QModelIndexList indexes = selectionModel()->selectedIndexes();
+ if (indexes.count() == 0)
+ return;
+
+ QMimeData* data = model()->mimeData(indexes);
+ if (!data) {
+ return;
+ }
+ QRect rect;
+ QPixmap pixmap = renderToPixmap(indexes, &rect);
+ QDrag* drag = new QDrag(this);
+ drag->setPixmap(pixmap);
+ drag->setMimeData(data);
+ drag->setHotSpot(m_pressedPosition - rect.topLeft());
+ Qt::DropAction defaultDropAction = Qt::IgnoreAction;
+ if (this->defaultDropAction() != Qt::IgnoreAction &&
+ (supportedActions & this->defaultDropAction())) {
+ defaultDropAction = this->defaultDropAction();
+ }
+ /*auto action = */
+ drag->exec(supportedActions, defaultDropAction);
+}
+
+QRect InstanceView::visualRect(const QModelIndex& index) const
+{
+ const_cast<InstanceView*>(this)->executeDelayedItemsLayout();
+
+ return geometryRect(index).translated(-offset());
+}
+
+QRect InstanceView::geometryRect(const QModelIndex& index) const
+{
+ const_cast<InstanceView*>(this)->executeDelayedItemsLayout();
+
+ if (!index.isValid() || isIndexHidden(index) || index.column() > 0) {
+ return QRect();
+ }
+
+ int row = index.row();
+ if (geometryCache.contains(row)) {
+ return *geometryCache[row];
+ }
+
+ const VisualGroup* cat = category(index);
+ QPair<int, int> pos = cat->positionOf(index);
+ int x = pos.first;
+ // int y = pos.second;
+
+ QRect out;
+ out.setTop(cat->verticalPosition() + cat->headerHeight() + 5 +
+ cat->rowTopOf(index));
+ out.setLeft(m_spacing + x * (itemWidth() + m_spacing));
+ out.setSize(itemDelegate()->sizeHint(viewOptions(), index));
+ geometryCache.insert(row, new QRect(out));
+ return out;
+}
+
+QModelIndex InstanceView::indexAt(const QPoint& point) const
+{
+ const_cast<InstanceView*>(this)->executeDelayedItemsLayout();
+
+ for (int i = 0; i < model()->rowCount(); ++i) {
+ QModelIndex index = model()->index(i, 0);
+ if (visualRect(index).contains(point)) {
+ return index;
+ }
+ }
+ return QModelIndex();
+}
+
+void InstanceView::setSelection(
+ const QRect& rect, const QItemSelectionModel::SelectionFlags commands)
+{
+ executeDelayedItemsLayout();
+
+ for (int i = 0; i < model()->rowCount(); ++i) {
+ QModelIndex index = model()->index(i, 0);
+ QRect itemRect = visualRect(index);
+ if (itemRect.intersects(rect)) {
+ selectionModel()->select(index, commands);
+ update(itemRect.translated(-offset()));
+ }
+ }
+}
+
+QPixmap InstanceView::renderToPixmap(const QModelIndexList& indices,
+ QRect* r) const
+{
+ Q_ASSERT(r);
+ auto paintPairs = draggablePaintPairs(indices, r);
+ if (paintPairs.isEmpty()) {
+ return QPixmap();
+ }
+ QPixmap pixmap(r->size());
+ pixmap.fill(Qt::transparent);
+ QPainter painter(&pixmap);
+ QStyleOptionViewItem option = viewOptions();
+ option.state |= QStyle::State_Selected;
+ for (int j = 0; j < paintPairs.count(); ++j) {
+ option.rect = paintPairs.at(j).first.translated(-r->topLeft());
+ const QModelIndex& current = paintPairs.at(j).second;
+ itemDelegate()->paint(&painter, option, current);
+ }
+ return pixmap;
+}
+
+QList<QPair<QRect, QModelIndex>>
+InstanceView::draggablePaintPairs(const QModelIndexList& indices,
+ QRect* r) const
+{
+ Q_ASSERT(r);
+ QRect& rect = *r;
+ QList<QPair<QRect, QModelIndex>> ret;
+ for (int i = 0; i < indices.count(); ++i) {
+ const QModelIndex& index = indices.at(i);
+ const QRect current = geometryRect(index);
+ ret += qMakePair(current, index);
+ rect |= current;
+ }
+ return ret;
+}
+
+bool InstanceView::isDragEventAccepted(QDropEvent* event)
+{
+ return true;
+}
+
+QPair<VisualGroup*, VisualGroup::HitResults>
+InstanceView::rowDropPos(const QPoint& pos)
+{
+ VisualGroup::HitResults hitresult;
+ auto group = categoryAt(pos + offset(), hitresult);
+ return qMakePair(group, hitresult);
+}
+
+QPoint InstanceView::offset() const
+{
+ return QPoint(horizontalOffset(), verticalOffset());
+}
+
+QRegion
+InstanceView::visualRegionForSelection(const QItemSelection& selection) const
+{
+ QRegion region;
+ for (auto& range : selection) {
+ int start_row = range.top();
+ int end_row = range.bottom();
+ for (int row = start_row; row <= end_row; ++row) {
+ int start_column = range.left();
+ int end_column = range.right();
+ for (int column = start_column; column <= end_column; ++column) {
+ QModelIndex index = model()->index(row, column, rootIndex());
+ region += visualRect(index); // OK
+ }
+ }
+ }
+ return region;
+}
+
+QModelIndex
+InstanceView::moveCursor(QAbstractItemView::CursorAction cursorAction,
+ Qt::KeyboardModifiers modifiers)
+{
+ auto current = currentIndex();
+ if (!current.isValid()) {
+ return current;
+ }
+ auto cat = category(current);
+ int group_index = m_groups.indexOf(cat);
+ if (group_index < 0)
+ return current;
+
+ QPair<int, int> pos = cat->positionOf(current);
+ int column = pos.first;
+ int row = pos.second;
+ if (m_currentCursorColumn < 0) {
+ m_currentCursorColumn = column;
+ }
+ switch (cursorAction) {
+ case MoveUp: {
+ if (row == 0) {
+ int prevgroupindex = group_index - 1;
+ while (prevgroupindex >= 0) {
+ auto prevgroup = m_groups[prevgroupindex];
+ if (prevgroup->collapsed) {
+ prevgroupindex--;
+ continue;
+ }
+ int newRow = prevgroup->numRows() - 1;
+ int newRowSize = prevgroup->rows[newRow].size();
+ int newColumn = m_currentCursorColumn;
+ if (m_currentCursorColumn >= newRowSize) {
+ newColumn = newRowSize - 1;
+ }
+ return prevgroup->rows[newRow][newColumn];
+ }
+ } else {
+ int newRow = row - 1;
+ int newRowSize = cat->rows[newRow].size();
+ int newColumn = m_currentCursorColumn;
+ if (m_currentCursorColumn >= newRowSize) {
+ newColumn = newRowSize - 1;
+ }
+ return cat->rows[newRow][newColumn];
+ }
+ return current;
+ }
+ case MoveDown: {
+ if (row == cat->rows.size() - 1) {
+ int nextgroupindex = group_index + 1;
+ while (nextgroupindex < m_groups.size()) {
+ auto nextgroup = m_groups[nextgroupindex];
+ if (nextgroup->collapsed) {
+ nextgroupindex++;
+ continue;
+ }
+ int newRowSize = nextgroup->rows[0].size();
+ int newColumn = m_currentCursorColumn;
+ if (m_currentCursorColumn >= newRowSize) {
+ newColumn = newRowSize - 1;
+ }
+ return nextgroup->rows[0][newColumn];
+ }
+ } else {
+ int newRow = row + 1;
+ int newRowSize = cat->rows[newRow].size();
+ int newColumn = m_currentCursorColumn;
+ if (m_currentCursorColumn >= newRowSize) {
+ newColumn = newRowSize - 1;
+ }
+ return cat->rows[newRow][newColumn];
+ }
+ return current;
+ }
+ case MoveLeft: {
+ if (column > 0) {
+ m_currentCursorColumn = column - 1;
+ return cat->rows[row][column - 1];
+ }
+ // TODO: moving to previous line
+ return current;
+ }
+ case MoveRight: {
+ if (column < cat->rows[row].size() - 1) {
+ m_currentCursorColumn = column + 1;
+ return cat->rows[row][column + 1];
+ }
+ // TODO: moving to next line
+ return current;
+ }
+ case MoveHome: {
+ m_currentCursorColumn = 0;
+ return cat->rows[row][0];
+ }
+ case MoveEnd: {
+ auto last = cat->rows[row].size() - 1;
+ m_currentCursorColumn = last;
+ return cat->rows[row][last];
+ }
+ default:
+ break;
+ }
+ return current;
+}
+
+int InstanceView::horizontalOffset() const
+{
+ return horizontalScrollBar()->value();
+}
+
+int InstanceView::verticalOffset() const
+{
+ return verticalScrollBar()->value();
+}
+
+void InstanceView::scrollContentsBy(int dx, int dy)
+{
+ scrollDirtyRegion(dx, dy);
+ viewport()->scroll(dx, dy);
+}
+
+void InstanceView::scrollTo(const QModelIndex& index, ScrollHint hint)
+{
+ if (!index.isValid())
+ return;
+
+ const QRect rect = visualRect(index);
+ if (hint == EnsureVisible && viewport()->rect().contains(rect)) {
+ viewport()->update(rect);
+ return;
+ }
+
+ verticalScrollBar()->setValue(verticalScrollToValue(index, rect, hint));
+}
+
+int InstanceView::verticalScrollToValue(const QModelIndex& index,
+ const QRect& rect,
+ QListView::ScrollHint hint) const
+{
+ const QRect area = viewport()->rect();
+ const bool above =
+ (hint == QListView::EnsureVisible && rect.top() < area.top());
+ const bool below =
+ (hint == QListView::EnsureVisible && rect.bottom() > area.bottom());
+
+ int verticalValue = verticalScrollBar()->value();
+ QRect adjusted =
+ rect.adjusted(-spacing(), -spacing(), spacing(), spacing());
+ if (hint == QListView::PositionAtTop || above)
+ verticalValue += adjusted.top();
+ else if (hint == QListView::PositionAtBottom || below)
+ verticalValue +=
+ qMin(adjusted.top(), adjusted.bottom() - area.height() + 1);
+ else if (hint == QListView::PositionAtCenter)
+ verticalValue +=
+ adjusted.top() - ((area.height() - adjusted.height()) / 2);
+ return verticalValue;
+}
diff --git a/meshmc/launcher/ui/instanceview/InstanceView.h b/meshmc/launcher/ui/instanceview/InstanceView.h
new file mode 100644
index 0000000000..5eb3f78d98
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/InstanceView.h
@@ -0,0 +1,192 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include <QListView>
+#include <QLineEdit>
+#include <QScrollBar>
+#include <QCache>
+#include "VisualGroup.h"
+#include <functional>
+
+struct InstanceViewRoles {
+ enum { GroupRole = Qt::UserRole, ProgressValueRole, ProgressMaximumRole };
+};
+
+class InstanceView : public QAbstractItemView
+{
+ Q_OBJECT
+
+ public:
+ InstanceView(QWidget* parent = 0);
+ ~InstanceView();
+
+ QStyleOptionViewItem viewOptions() const
+ {
+ QStyleOptionViewItem option;
+ initViewItemOption(&option);
+ return option;
+ }
+
+ void setModel(QAbstractItemModel* model) override;
+
+ using visibilityFunction = std::function<bool(const QString&)>;
+ void setSourceOfGroupCollapseStatus(visibilityFunction f)
+ {
+ fVisibility = f;
+ }
+
+ /// return geometry rectangle occupied by the specified model item
+ QRect geometryRect(const QModelIndex& index) const;
+ /// return visual rectangle occupied by the specified model item
+ virtual QRect visualRect(const QModelIndex& index) const override;
+ /// get the model index at the specified visual point
+ virtual QModelIndex indexAt(const QPoint& point) const override;
+ QString groupNameAt(const QPoint& point);
+ void
+ setSelection(const QRect& rect,
+ const QItemSelectionModel::SelectionFlags commands) override;
+
+ virtual int horizontalOffset() const override;
+ virtual int verticalOffset() const override;
+ virtual void scrollContentsBy(int dx, int dy) override;
+ virtual void scrollTo(const QModelIndex& index,
+ ScrollHint hint = EnsureVisible) override;
+
+ virtual QModelIndex moveCursor(CursorAction cursorAction,
+ Qt::KeyboardModifiers modifiers) override;
+
+ virtual QRegion
+ visualRegionForSelection(const QItemSelection& selection) const override;
+
+ int spacing() const
+ {
+ return m_spacing;
+ };
+
+ public slots:
+ virtual void updateGeometries() override;
+
+ protected slots:
+ virtual void dataChanged(const QModelIndex& topLeft,
+ const QModelIndex& bottomRight,
+ const QVector<int>& roles) override;
+ virtual void rowsInserted(const QModelIndex& parent, int start,
+ int end) override;
+ virtual void rowsAboutToBeRemoved(const QModelIndex& parent, int start,
+ int end) override;
+ void modelReset();
+ void rowsRemoved();
+ void currentChanged(const QModelIndex& current,
+ const QModelIndex& previous) override;
+
+ signals:
+ void droppedURLs(QList<QUrl> urls);
+ void groupStateChanged(QString group, bool collapsed);
+
+ protected:
+ bool isIndexHidden(const QModelIndex& index) const override;
+ void mousePressEvent(QMouseEvent* event) override;
+ void mouseMoveEvent(QMouseEvent* event) override;
+ void mouseReleaseEvent(QMouseEvent* event) override;
+ void mouseDoubleClickEvent(QMouseEvent* event) override;
+ void paintEvent(QPaintEvent* event) override;
+ void resizeEvent(QResizeEvent* event) override;
+
+ void dragEnterEvent(QDragEnterEvent* event) override;
+ void dragMoveEvent(QDragMoveEvent* event) override;
+ void dragLeaveEvent(QDragLeaveEvent* event) override;
+ void dropEvent(QDropEvent* event) override;
+
+ void startDrag(Qt::DropActions supportedActions) override;
+
+ void updateScrollbar();
+
+ private:
+ friend struct VisualGroup;
+ QList<VisualGroup*> m_groups;
+
+ visibilityFunction fVisibility;
+
+ // geometry
+ int m_leftMargin = 5;
+ int m_rightMargin = 5;
+ int m_bottomMargin = 5;
+ int m_categoryMargin = 5;
+ int m_spacing = 5;
+ int m_itemWidth = 100;
+ int m_currentItemsPerRow = -1;
+ int m_currentCursorColumn = -1;
+ mutable QCache<int, QRect> geometryCache;
+
+ // point where the currently active mouse action started in geometry
+ // coordinates
+ QPoint m_pressedPosition;
+ QPersistentModelIndex m_pressedIndex;
+ bool m_pressedAlreadySelected;
+ VisualGroup* m_pressedCategory;
+ QItemSelectionModel::SelectionFlag m_ctrlDragSelectionFlag;
+ QPoint m_lastDragPosition;
+
+ VisualGroup* category(const QModelIndex& index) const;
+ VisualGroup* category(const QString& cat) const;
+ VisualGroup* categoryAt(const QPoint& pos,
+ VisualGroup::HitResults& result) const;
+
+ int itemsPerRow() const
+ {
+ return m_currentItemsPerRow;
+ };
+ int contentWidth() const;
+
+ private: /* methods */
+ int itemWidth() const;
+ int calculateItemsPerRow() const;
+ int verticalScrollToValue(const QModelIndex& index, const QRect& rect,
+ QListView::ScrollHint hint) const;
+ QPixmap renderToPixmap(const QModelIndexList& indices, QRect* r) const;
+ QList<QPair<QRect, QModelIndex>>
+ draggablePaintPairs(const QModelIndexList& indices, QRect* r) const;
+
+ bool isDragEventAccepted(QDropEvent* event);
+
+ QPair<VisualGroup*, VisualGroup::HitResults> rowDropPos(const QPoint& pos);
+
+ QPoint offset() const;
+};
diff --git a/meshmc/launcher/ui/instanceview/VisualGroup.cpp b/meshmc/launcher/ui/instanceview/VisualGroup.cpp
new file mode 100644
index 0000000000..aab6adc1b6
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/VisualGroup.cpp
@@ -0,0 +1,334 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "VisualGroup.h"
+
+#include <QModelIndex>
+#include <QPainter>
+#include <QtMath>
+#include <QApplication>
+#include <QDebug>
+
+#include "InstanceView.h"
+
+VisualGroup::VisualGroup(const QString& text, InstanceView* view)
+ : view(view), text(text), collapsed(false)
+{
+}
+
+VisualGroup::VisualGroup(const VisualGroup* other)
+ : view(other->view), text(other->text), collapsed(other->collapsed)
+{
+}
+
+void VisualGroup::update()
+{
+ auto temp_items = items();
+ auto itemsPerRow = view->itemsPerRow();
+
+ int numRows = qMax(1, qCeil((qreal)temp_items.size() / (qreal)itemsPerRow));
+ rows = QVector<VisualRow>(numRows);
+
+ int maxRowHeight = 0;
+ int positionInRow = 0;
+ int currentRow = 0;
+ int offsetFromTop = 0;
+ for (auto item : temp_items) {
+ if (positionInRow == itemsPerRow) {
+ rows[currentRow].height = maxRowHeight;
+ rows[currentRow].top = offsetFromTop;
+ currentRow++;
+ offsetFromTop += maxRowHeight + 5;
+ positionInRow = 0;
+ maxRowHeight = 0;
+ }
+ auto itemHeight =
+ view->itemDelegate()->sizeHint(view->viewOptions(), item).height();
+ if (itemHeight > maxRowHeight) {
+ maxRowHeight = itemHeight;
+ }
+ rows[currentRow].items.append(item);
+ positionInRow++;
+ }
+ rows[currentRow].height = maxRowHeight;
+ rows[currentRow].top = offsetFromTop;
+}
+
+QPair<int, int> VisualGroup::positionOf(const QModelIndex& index) const
+{
+ int y = 0;
+ for (auto& row : rows) {
+ for (auto x = 0; x < row.items.size(); x++) {
+ if (row.items[x] == index) {
+ return qMakePair(x, y);
+ }
+ }
+ y++;
+ }
+ qWarning() << "Item" << index.row()
+ << index.data(Qt::DisplayRole).toString()
+ << "not found in visual group" << text;
+ return qMakePair(0, 0);
+}
+
+int VisualGroup::rowTopOf(const QModelIndex& index) const
+{
+ auto position = positionOf(index);
+ return rows[position.second].top;
+}
+
+int VisualGroup::rowHeightOf(const QModelIndex& index) const
+{
+ auto position = positionOf(index);
+ return rows[position.second].height;
+}
+
+VisualGroup::HitResults VisualGroup::hitScan(const QPoint& pos) const
+{
+ VisualGroup::HitResults results = VisualGroup::NoHit;
+ int y_start = verticalPosition();
+ int body_start = y_start + headerHeight();
+ int body_end = body_start + contentHeight() + 5; // FIXME: wtf is this 5?
+ int y = pos.y();
+ // int x = pos.x();
+ if (y < y_start) {
+ results = VisualGroup::NoHit;
+ } else if (y < body_start) {
+ results = VisualGroup::HeaderHit;
+ int collapseSize = headerHeight() - 4;
+
+ // the icon
+ QRect iconRect = QRect(view->m_leftMargin + 2, 2 + y_start,
+ collapseSize, collapseSize);
+ if (iconRect.contains(pos)) {
+ results |= VisualGroup::CheckboxHit;
+ }
+ } else if (y < body_end) {
+ results |= VisualGroup::BodyHit;
+ }
+ return results;
+}
+
+void VisualGroup::drawHeader(QPainter* painter,
+ const QStyleOptionViewItem& option)
+{
+ painter->setRenderHint(QPainter::Antialiasing);
+
+ const QRect optRect = option.rect;
+ QFont font(QApplication::font());
+ font.setBold(true);
+ const QFontMetrics fontMetrics = QFontMetrics(font);
+
+ QColor outlineColor = option.palette.text().color();
+ outlineColor.setAlphaF(0.35);
+
+ // BEGIN: top left corner
+ {
+ painter->save();
+ painter->setPen(outlineColor);
+ const QPointF topLeft(optRect.topLeft());
+ QRectF arc(topLeft, QSizeF(4, 4));
+ arc.translate(0.5, 0.5);
+ painter->drawArc(arc, 1440, 1440);
+ painter->restore();
+ }
+ // END: top left corner
+
+ // BEGIN: left vertical line
+ {
+ QPoint start(optRect.topLeft());
+ start.ry() += 3;
+ QPoint verticalGradBottom(optRect.topLeft());
+ verticalGradBottom.ry() += fontMetrics.height() + 5;
+ QLinearGradient gradient(start, verticalGradBottom);
+ gradient.setColorAt(0, outlineColor);
+ gradient.setColorAt(1, Qt::transparent);
+ painter->fillRect(QRect(start, QSize(1, fontMetrics.height() + 5)),
+ gradient);
+ }
+ // END: left vertical line
+
+ // BEGIN: horizontal line
+ {
+ QPoint start(optRect.topLeft());
+ start.rx() += 3;
+ QPoint horizontalGradTop(optRect.topLeft());
+ horizontalGradTop.rx() += optRect.width() - 6;
+ painter->fillRect(QRect(start, QSize(optRect.width() - 6, 1)),
+ outlineColor);
+ }
+ // END: horizontal line
+
+ // BEGIN: top right corner
+ {
+ painter->save();
+ painter->setPen(outlineColor);
+ QPointF topRight(optRect.topRight());
+ topRight.rx() -= 4;
+ QRectF arc(topRight, QSizeF(4, 4));
+ arc.translate(0.5, 0.5);
+ painter->drawArc(arc, 0, 1440);
+ painter->restore();
+ }
+ // END: top right corner
+
+ // BEGIN: right vertical line
+ {
+ QPoint start(optRect.topRight());
+ start.ry() += 3;
+ QPoint verticalGradBottom(optRect.topRight());
+ verticalGradBottom.ry() += fontMetrics.height() + 5;
+ QLinearGradient gradient(start, verticalGradBottom);
+ gradient.setColorAt(0, outlineColor);
+ gradient.setColorAt(1, Qt::transparent);
+ painter->fillRect(QRect(start, QSize(1, fontMetrics.height() + 5)),
+ gradient);
+ }
+ // END: right vertical line
+
+ // BEGIN: checkboxy thing
+ {
+ painter->save();
+ painter->setRenderHint(QPainter::Antialiasing, false);
+ painter->setFont(font);
+ QColor penColor(option.palette.text().color());
+ penColor.setAlphaF(0.6);
+ painter->setPen(penColor);
+ QRect iconSubRect(option.rect);
+ iconSubRect.setTop(iconSubRect.top() + 7);
+ iconSubRect.setLeft(iconSubRect.left() + 7);
+
+ int sizing = fontMetrics.height();
+ int even = ((sizing - 1) % 2);
+
+ iconSubRect.setHeight(sizing - even);
+ iconSubRect.setWidth(sizing - even);
+ painter->drawRect(iconSubRect);
+
+ /*
+ if(collapsed)
+ painter->drawText(iconSubRect, Qt::AlignHCenter | Qt::AlignVCenter,
+ "+"); else painter->drawText(iconSubRect, Qt::AlignHCenter |
+ Qt::AlignVCenter, "-");
+ */
+ painter->setBrush(option.palette.text());
+ painter->fillRect(iconSubRect.x(),
+ iconSubRect.y() + iconSubRect.height() / 2,
+ iconSubRect.width(), 2, penColor);
+ if (collapsed) {
+ painter->fillRect(iconSubRect.x() + iconSubRect.width() / 2,
+ iconSubRect.y(), 2, iconSubRect.height(),
+ penColor);
+ }
+
+ painter->restore();
+ }
+ // END: checkboxy thing
+
+ // BEGIN: text
+ {
+ QRect textRect(option.rect);
+ textRect.setTop(textRect.top() + 7);
+ textRect.setLeft(textRect.left() + 7 + fontMetrics.height() + 7);
+ textRect.setHeight(fontMetrics.height());
+ textRect.setRight(textRect.right() - 7);
+
+ painter->save();
+ painter->setFont(font);
+ QColor penColor(option.palette.text().color());
+ penColor.setAlphaF(0.6);
+ painter->setPen(penColor);
+ painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, text);
+ painter->restore();
+ }
+ // END: text
+}
+
+int VisualGroup::totalHeight() const
+{
+ return headerHeight() + 5 + contentHeight(); // FIXME: wtf is that '5'?
+}
+
+int VisualGroup::headerHeight() const
+{
+ QFont font(QApplication::font());
+ font.setBold(true);
+ QFontMetrics fontMetrics(font);
+
+ const int height = fontMetrics.height() + 1 /* 1 pixel-width gradient */
+ + 11 /* top and bottom separation */;
+ return height;
+ /*
+ int raw = view->viewport()->fontMetrics().height() + 4;
+ // add english. maybe. depends on font height.
+ if (raw % 2 == 0)
+ raw++;
+ return std::min(raw, 25);
+ */
+}
+
+int VisualGroup::contentHeight() const
+{
+ if (collapsed) {
+ return 0;
+ }
+ auto last = rows[numRows() - 1];
+ return last.top + last.height;
+}
+
+int VisualGroup::numRows() const
+{
+ return rows.size();
+}
+
+int VisualGroup::verticalPosition() const
+{
+ return m_verticalPosition;
+}
+
+QList<QModelIndex> VisualGroup::items() const
+{
+ QList<QModelIndex> indices;
+ for (int i = 0; i < view->model()->rowCount(); ++i) {
+ const QModelIndex index = view->model()->index(i, 0);
+ if (index.data(InstanceViewRoles::GroupRole).toString() == text) {
+ indices.append(index);
+ }
+ }
+ return indices;
+}
diff --git a/meshmc/launcher/ui/instanceview/VisualGroup.h b/meshmc/launcher/ui/instanceview/VisualGroup.h
new file mode 100644
index 0000000000..9ef7771d03
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/VisualGroup.h
@@ -0,0 +1,126 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include <QString>
+#include <QRect>
+#include <QVector>
+#include <QStyleOption>
+
+class InstanceView;
+class QPainter;
+class QModelIndex;
+
+struct VisualRow {
+ QList<QModelIndex> items;
+ int height = 0;
+ int top = 0;
+ inline int size() const
+ {
+ return items.size();
+ }
+ inline QModelIndex& operator[](int i)
+ {
+ return items[i];
+ }
+};
+
+struct VisualGroup {
+ /* constructors */
+ VisualGroup(const QString& text, InstanceView* view);
+ VisualGroup(const VisualGroup* other);
+
+ /* data */
+ InstanceView* view = nullptr;
+ QString text;
+ bool collapsed = false;
+ QVector<VisualRow> rows;
+ int firstItemIndex = 0;
+ int m_verticalPosition = 0;
+
+ /* logic */
+ /// update the internal list of items and flow them into the rows.
+ void update();
+
+ /// draw the header at y-position.
+ void drawHeader(QPainter* painter, const QStyleOptionViewItem& option);
+
+ /// height of the group, in total. includes a small bit of padding.
+ int totalHeight() const;
+
+ /// height of the group header, in pixels
+ int headerHeight() const;
+
+ /// height of the group content, in pixels
+ int contentHeight() const;
+
+ /// the number of visual rows this group has
+ int numRows() const;
+
+ /// actually calculate the above value
+ int calculateNumRows() const;
+
+ /// the height at which this group starts, in pixels
+ int verticalPosition() const;
+
+ /// relative geometry - top of the row of the given item
+ int rowTopOf(const QModelIndex& index) const;
+
+ /// height of the row of the given item
+ int rowHeightOf(const QModelIndex& index) const;
+
+ /// x/y position of the given item inside the group (in items!)
+ QPair<int, int> positionOf(const QModelIndex& index) const;
+
+ enum HitResult {
+ NoHit = 0x0,
+ TextHit = 0x1,
+ CheckboxHit = 0x2,
+ HeaderHit = 0x4,
+ BodyHit = 0x8
+ };
+ Q_DECLARE_FLAGS(HitResults, HitResult)
+
+ /// shoot! BANG! what did we hit?
+ HitResults hitScan(const QPoint& pos) const;
+
+ QList<QModelIndex> items() const;
+};
+
+Q_DECLARE_OPERATORS_FOR_FLAGS(VisualGroup::HitResults)