diff options
Diffstat (limited to 'meshmc/launcher/ui/instanceview')
| -rw-r--r-- | meshmc/launcher/ui/instanceview/AccessibleInstanceView.cpp | 859 | ||||
| -rw-r--r-- | meshmc/launcher/ui/instanceview/AccessibleInstanceView.h | 28 | ||||
| -rw-r--r-- | meshmc/launcher/ui/instanceview/AccessibleInstanceView_p.h | 155 | ||||
| -rw-r--r-- | meshmc/launcher/ui/instanceview/InstanceDelegate.cpp | 462 | ||||
| -rw-r--r-- | meshmc/launcher/ui/instanceview/InstanceDelegate.h | 69 | ||||
| -rw-r--r-- | meshmc/launcher/ui/instanceview/InstanceProxyModel.cpp | 100 | ||||
| -rw-r--r-- | meshmc/launcher/ui/instanceview/InstanceProxyModel.h | 60 | ||||
| -rw-r--r-- | meshmc/launcher/ui/instanceview/InstanceView.cpp | 952 | ||||
| -rw-r--r-- | meshmc/launcher/ui/instanceview/InstanceView.h | 192 | ||||
| -rw-r--r-- | meshmc/launcher/ui/instanceview/VisualGroup.cpp | 334 | ||||
| -rw-r--r-- | meshmc/launcher/ui/instanceview/VisualGroup.h | 126 |
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) |
