shift-shell/containments/homescreens/folio/favouritesmodel.cpp
Marco Allegretti 9bf7b70d2b Fix homescreen includes, accessibility, and popup clamp
Remove duplicate KService include in favouritesmodel.cpp. Add
keyboard navigation and accessibility role to CategoryPanel
tiles. Clamp the FavouritesBar thumbnail popup position to
screen bounds so it does not overflow offscreen on edge items.
2026-04-18 20:25:05 +02:00

450 lines
13 KiB
C++

// SPDX-FileCopyrightText: 2023 Devin Lin <devin@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
#include "favouritesmodel.h"
#include "homescreenstate.h"
#include <QByteArray>
#include <QDebug>
#include <QJsonArray>
#include <QJsonDocument>
#include <QModelIndex>
#include <QProcess>
#include <QQuickWindow>
#include <KApplicationTrader>
#include <KConfigGroup>
#include <KIO/ApplicationLauncherJob>
#include <KNotificationJobUiDelegate>
#include <KService>
#include <KSharedConfig>
#include <KSycoca>
FavouritesModel::FavouritesModel(HomeScreen *parent)
: QAbstractListModel{parent}
, m_homeScreen{parent}
{
// Listen to application removal events and delete delegates
connect(m_homeScreen->applicationListModel(), &ApplicationListModel::applicationRemoved, this, [this](const QString &storageId) {
for (int i = 0; i < m_delegates.size(); i++) {
auto delegate = m_delegates[i].delegate;
if (delegate->type() == FolioDelegate::Application && delegate->application()->storageId() == storageId) {
removeEntry(i);
}
}
});
}
int FavouritesModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent)
return m_delegates.count();
}
QVariant FavouritesModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_delegates.size()) {
return QVariant();
}
switch (role) {
case DelegateRole:
return QVariant::fromValue(m_delegates.at(index.row()).delegate.get());
}
return QVariant();
}
QHash<int, QByteArray> FavouritesModel::roleNames() const
{
return {{DelegateRole, "delegate"}};
}
void FavouritesModel::removeEntry(int row)
{
if (row < 0 || row >= m_delegates.size()) {
return;
}
beginRemoveRows(QModelIndex(), row, row);
// HACK: do not deleteLater(), because the delegate might still be used somewhere else
// m_delegates[row].delegate->deleteLater();
m_delegates.removeAt(row);
endRemoveRows();
save();
}
bool FavouritesModel::addApplication(const QString &storageId)
{
return addApplicationAt(m_delegates.size(), storageId);
}
bool FavouritesModel::addApplicationAt(int row, const QString &storageId)
{
if (containsApplication(storageId)) {
return false;
}
KService::Ptr service = KService::serviceByStorageId(storageId);
if (!service) {
return false;
}
auto app = std::make_shared<FolioApplication>(service);
auto delegate = std::make_shared<FolioDelegate>(app);
return addEntry(row, delegate);
}
bool FavouritesModel::containsApplication(const QString &storageId) const
{
for (const auto &entry : m_delegates) {
if (entry.delegate && entry.delegate->type() == FolioDelegate::Application) {
auto app = entry.delegate->application();
if (app && app->storageId() == storageId) {
return true;
}
}
}
return false;
}
void FavouritesModel::moveEntry(int fromRow, int toRow)
{
if (fromRow < 0 || toRow < 0 || fromRow >= m_delegates.size() || toRow >= m_delegates.size() || fromRow == toRow) {
return;
}
if (toRow > fromRow) {
++toRow;
}
beginMoveRows(QModelIndex(), fromRow, fromRow, QModelIndex(), toRow);
if (toRow > fromRow) {
auto delegate = m_delegates.at(fromRow);
m_delegates.insert(toRow, delegate);
m_delegates.takeAt(fromRow);
} else {
auto delegate = m_delegates.takeAt(fromRow);
m_delegates.insert(toRow, delegate);
}
endMoveRows();
save();
}
bool FavouritesModel::canAddEntry(int row, FolioDelegate::Ptr delegate)
{
if (!delegate) {
return false;
}
if (row < 0 || row > m_delegates.size()) {
return false;
}
return true;
}
bool FavouritesModel::addEntry(int row, FolioDelegate::Ptr delegate)
{
if (!canAddEntry(row, delegate)) {
return false;
}
if (row == m_delegates.size()) {
beginInsertRows(QModelIndex(), row, row);
m_delegates.append({delegate, 0});
endInsertRows();
} else if (m_delegates[row].delegate->type() == FolioDelegate::None) {
replaceGhostEntry(delegate);
} else {
beginInsertRows(QModelIndex(), row, row);
m_delegates.insert(row, {delegate, 0});
endInsertRows();
}
// ensure saves are connected when requested by the delegate
connectSaveRequests(delegate);
save();
return true;
}
FolioDelegate::Ptr FavouritesModel::getEntryAt(int row)
{
if (row < 0 || row >= m_delegates.size()) {
return nullptr;
}
return m_delegates[row].delegate;
}
bool FavouritesModel::isFull() const
{
auto homeScreenState = m_homeScreen->homeScreenState();
bool isLocationBottom = homeScreenState->favouritesBarLocation() == HomeScreenState::Bottom;
// we should not include the ghost entry in the delegate count
int count = 0;
for (const auto &delegate : m_delegates) {
if (delegate.delegate->type() == FolioDelegate::None) {
continue;
}
++count;
}
if (isLocationBottom) {
return count >= homeScreenState->pageColumns();
} else {
return count >= homeScreenState->pageRows();
}
}
int FavouritesModel::getGhostEntryPosition()
{
for (int i = 0; i < m_delegates.size(); i++) {
if (m_delegates[i].delegate->type() == FolioDelegate::None) {
return i;
}
}
return -1;
}
void FavouritesModel::setGhostEntry(int row)
{
bool found = false;
// check if a ghost entry already exists, then swap them
for (int i = 0; i < m_delegates.size(); i++) {
if (m_delegates[i].delegate->type() == FolioDelegate::None) {
found = true;
if (row != i) {
moveEntry(i, row);
}
}
}
// if it doesn't, add a new empty delegate
if (!found) {
FolioDelegate::Ptr ghost = std::make_shared<FolioDelegate>();
addEntry(row, ghost);
}
}
void FavouritesModel::replaceGhostEntry(FolioDelegate::Ptr delegate)
{
for (int i = 0; i < m_delegates.size(); i++) {
if (m_delegates[i].delegate->type() == FolioDelegate::None) {
m_delegates[i].delegate->deleteLater();
m_delegates[i].delegate = delegate;
Q_EMIT dataChanged(createIndex(i, 0), createIndex(i, 0), {DelegateRole});
break;
}
}
}
void FavouritesModel::deleteGhostEntry()
{
for (int i = 0; i < m_delegates.size(); i++) {
if (m_delegates[i].delegate->type() == FolioDelegate::None) {
auto ghostEntry = m_delegates[i].delegate;
removeEntry(i);
// ensure ghost entry is deleted
ghostEntry->deleteLater();
}
}
}
QJsonArray FavouritesModel::exportToJson()
{
QJsonArray arr;
for (int i = 0; i < m_delegates.size(); i++) {
FolioDelegate::Ptr delegate = m_delegates[i].delegate;
// if this delegate is empty, ignore it
if (!delegate || delegate->type() == FolioDelegate::None) {
continue;
}
arr.append(delegate->toJson());
}
return arr;
}
void FavouritesModel::save()
{
if (!m_homeScreen) {
return;
}
QJsonArray arr = exportToJson();
QByteArray data = QJsonDocument(arr).toJson(QJsonDocument::Compact);
m_homeScreen->folioSettings()->setFavorites(QString::fromStdString(data.toStdString()));
}
void FavouritesModel::load()
{
if (!m_homeScreen) {
return;
}
QJsonDocument doc = QJsonDocument::fromJson(m_homeScreen->folioSettings()->favorites().toUtf8());
loadFromJson(doc.array());
}
void FavouritesModel::loadFromJson(QJsonArray arr)
{
beginResetModel();
m_delegates.clear();
for (QJsonValueRef r : arr) {
QJsonObject obj = r.toObject();
FolioDelegate::Ptr delegate = FolioDelegate::fromJson(obj, m_homeScreen);
if (delegate) {
connectSaveRequests(delegate);
m_delegates.append({delegate, 0});
}
}
endResetModel();
}
void FavouritesModel::connectSaveRequests(FolioDelegate::Ptr delegate)
{
if (delegate->type() == FolioDelegate::Folder && delegate->folder()) {
connect(delegate->folder().get(), &FolioApplicationFolder::saveRequested, this, &FavouritesModel::save);
}
}
bool FavouritesModel::dropPositionIsEdge(qreal x, qreal y) const
{
auto homeScreenState = m_homeScreen->homeScreenState();
qreal startPosition = getDelegateRowStartPos();
bool isLocationBottom = homeScreenState->favouritesBarLocation() == HomeScreenState::Bottom;
qreal cellLength = isLocationBottom ? homeScreenState->pageCellWidth() : homeScreenState->pageCellHeight();
qreal pos = isLocationBottom ? x : y;
if (pos < startPosition) {
return true;
}
qreal currentPos = startPosition;
for (int i = 0; i < m_delegates.size(); i++) {
// if it is within the center 70% of a delegate, it is not at an edge
if (pos >= (currentPos + cellLength * 0.15) && pos <= (currentPos + cellLength * 0.85)) {
return false;
}
currentPos += cellLength;
}
return true;
}
int FavouritesModel::dropInsertPosition(qreal x, qreal y) const
{
auto homeScreenState = m_homeScreen->homeScreenState();
qreal startPosition = getDelegateRowStartPos();
bool isLocationBottom = homeScreenState->favouritesBarLocation() == HomeScreenState::Bottom;
qreal cellLength = isLocationBottom ? homeScreenState->pageCellWidth() : homeScreenState->pageCellHeight();
qreal pos = isLocationBottom ? x : y;
if (pos < startPosition) {
return adjustIndex(0);
}
qreal currentPos = startPosition;
for (int i = 0; i < m_delegates.size(); i++) {
if (pos < currentPos + cellLength * 0.85) {
return adjustIndex(i);
} else if (pos < currentPos + cellLength) {
return adjustIndex(i + 1);
}
currentPos += cellLength;
}
return adjustIndex(m_delegates.size());
}
QPointF FavouritesModel::getDelegateScreenPosition(int position) const
{
position = adjustIndex(position);
auto homeScreenState = m_homeScreen->homeScreenState();
qreal screenHeight = homeScreenState->viewHeight();
qreal screenWidth = homeScreenState->viewWidth();
qreal pageHeight = homeScreenState->pageHeight();
qreal pageWidth = homeScreenState->pageWidth();
qreal screenTopPadding = homeScreenState->viewTopPadding();
qreal screenBottomPadding = homeScreenState->viewBottomPadding();
qreal screenLeftPadding = homeScreenState->viewLeftPadding();
qreal screenRightPadding = homeScreenState->viewRightPadding();
qreal cellHeight = homeScreenState->pageCellHeight();
qreal cellWidth = homeScreenState->pageCellWidth();
qreal startPosition = getDelegateRowStartPos();
switch (homeScreenState->favouritesBarLocation()) {
case HomeScreenState::Bottom: {
qreal favouritesHeight = screenHeight - pageHeight - screenBottomPadding - screenTopPadding;
qreal x = screenLeftPadding + startPosition + cellWidth * position;
qreal y = screenTopPadding + pageHeight + (favouritesHeight / 2) - (cellHeight / 2);
return {x, y};
}
case HomeScreenState::Left: {
qreal favouritesWidth = screenWidth - screenLeftPadding - pageWidth - screenRightPadding;
qreal x = screenLeftPadding + (favouritesWidth / 2) - (cellWidth / 2);
qreal y = startPosition + cellHeight * position;
return {x, y};
}
case HomeScreenState::Right: {
qreal favouritesWidth = screenWidth - screenLeftPadding - pageWidth - screenRightPadding;
qreal x = screenLeftPadding + pageWidth + (favouritesWidth / 2) - (cellWidth / 2);
qreal y = startPosition + cellHeight * position;
return {x, y};
}
}
return {0, 0};
}
qreal FavouritesModel::getDelegateRowStartPos() const
{
auto homeScreenState = m_homeScreen->homeScreenState();
const int length = m_delegates.size();
const bool isLocationBottom = homeScreenState->favouritesBarLocation() == HomeScreenState::Bottom;
const qreal cellLength = isLocationBottom ? homeScreenState->pageCellWidth() : homeScreenState->pageCellHeight();
const qreal pageLength = isLocationBottom ? homeScreenState->pageWidth() : homeScreenState->pageHeight();
const qreal topMargin = homeScreenState->viewTopPadding();
const qreal leftMargin = homeScreenState->viewLeftPadding();
const qreal panelOffset = isLocationBottom ? leftMargin : topMargin;
return (pageLength / 2) - (((qreal)length) / 2) * cellLength + panelOffset;
}
int FavouritesModel::adjustIndex(int index) const
{
auto homeScreenState = m_homeScreen->homeScreenState();
if (homeScreenState->favouritesBarLocation() == HomeScreenState::Bottom || homeScreenState->favouritesBarLocation() == HomeScreenState::Left) {
return index;
} else {
// if it's on the right side of the screen, we flip the order of the delegates
return qMax(0, m_delegates.size() - index - 1);
}
}