File indexing completed on 2025-03-02 05:11:58

0001 /*
0002     SPDX-FileCopyrightText: 2005-2007 Fredrik Höglund <fredrik@kde.org>
0003 
0004     SPDX-License-Identifier: GPL-2.0-only
0005 */
0006 
0007 #include <KConfig>
0008 #include <KConfigGroup>
0009 #include <KLocalizedString>
0010 
0011 #include <QDir>
0012 #include <QLoggingCategory>
0013 #include <QRegularExpression>
0014 #include <private/qtx11extras_p.h>
0015 
0016 #include "thememodel.h"
0017 #include "xcursortheme.h"
0018 
0019 #include <X11/Xcursor/Xcursor.h>
0020 #include <X11/Xlib.h>
0021 
0022 using namespace Qt::StringLiterals;
0023 
0024 // Check for older version
0025 #if !defined(XCURSOR_LIB_MAJOR) && defined(XCURSOR_MAJOR)
0026 #define XCURSOR_LIB_MAJOR XCURSOR_MAJOR
0027 #define XCURSOR_LIB_MINOR XCURSOR_MINOR
0028 #endif
0029 
0030 Q_LOGGING_CATEGORY(KCM_CURSORTHEME, "kcm_cursortheme", QtWarningMsg)
0031 
0032 CursorThemeModel::CursorThemeModel(QObject *parent)
0033     : QAbstractListModel(parent)
0034 {
0035     insertThemes();
0036 }
0037 
0038 CursorThemeModel::~CursorThemeModel()
0039 {
0040     qDeleteAll(list);
0041     list.clear();
0042 }
0043 
0044 QHash<int, QByteArray> CursorThemeModel::roleNames() const
0045 {
0046     QHash<int, QByteArray> roleNames = QAbstractListModel::roleNames();
0047     roleNames[CursorTheme::DisplayDetailRole] = "description";
0048     roleNames[CursorTheme::IsWritableRole] = "isWritable";
0049     roleNames[CursorTheme::PendingDeletionRole] = "pendingDeletion";
0050 
0051     return roleNames;
0052 }
0053 
0054 void CursorThemeModel::refreshList()
0055 {
0056     beginResetModel();
0057     qDeleteAll(list);
0058     list.clear();
0059     defaultName.clear();
0060     endResetModel();
0061     insertThemes();
0062 }
0063 
0064 QVariant CursorThemeModel::data(const QModelIndex &index, int role) const
0065 {
0066     if (!index.isValid() || index.row() < 0 || index.row() >= list.count())
0067         return QVariant();
0068 
0069     CursorTheme *theme = list.at(index.row());
0070 
0071     // Text label
0072     if (role == Qt::DisplayRole) {
0073         return theme->title();
0074     }
0075 
0076     // Description for the first name column
0077     if (role == CursorTheme::DisplayDetailRole)
0078         return theme->description();
0079 
0080     // Icon for the name column
0081     if (role == Qt::DecorationRole)
0082         return theme->icon();
0083 
0084     if (role == CursorTheme::IsWritableRole) {
0085         return theme->isWritable();
0086     }
0087 
0088     if (role == CursorTheme::PendingDeletionRole) {
0089         return pendingDeletions.contains(theme);
0090     }
0091 
0092     return QVariant();
0093 }
0094 
0095 bool CursorThemeModel::setData(const QModelIndex &index, const QVariant &value, int role)
0096 {
0097     if (!checkIndex(index, CheckIndexOption::IndexIsValid | CheckIndexOption::ParentIsInvalid)) {
0098         return false;
0099     }
0100     if (role == CursorTheme::PendingDeletionRole) {
0101         const bool shouldRemove = value.toBool();
0102         if (shouldRemove) {
0103             pendingDeletions.push_back(list[index.row()]);
0104         } else {
0105             pendingDeletions.removeAll(list[index.row()]);
0106         }
0107         Q_EMIT dataChanged(index, index, {role});
0108         return true;
0109     }
0110     return false;
0111 }
0112 
0113 void CursorThemeModel::sort(int column, Qt::SortOrder order)
0114 {
0115     Q_UNUSED(column);
0116     Q_UNUSED(order);
0117 
0118     // Sorting of the model isn't implemented, as the KCM currently uses
0119     // a sorting proxy model.
0120 }
0121 
0122 const CursorTheme *CursorThemeModel::theme(const QModelIndex &index)
0123 {
0124     if (!index.isValid())
0125         return nullptr;
0126 
0127     if (index.row() < 0 || index.row() >= list.count())
0128         return nullptr;
0129 
0130     return list.at(index.row());
0131 }
0132 
0133 QModelIndex CursorThemeModel::findIndex(const QString &name)
0134 {
0135     uint hash = qHash(name);
0136 
0137     for (int i = 0; i < list.count(); i++) {
0138         const CursorTheme *theme = list.at(i);
0139         if (theme->hash() == hash)
0140             return index(i, 0);
0141     }
0142 
0143     return QModelIndex();
0144 }
0145 
0146 QModelIndex CursorThemeModel::defaultIndex()
0147 {
0148     return findIndex(defaultName);
0149 }
0150 
0151 const QStringList CursorThemeModel::searchPaths()
0152 {
0153     if (!baseDirs.isEmpty())
0154         return baseDirs;
0155 
0156 #if XCURSOR_LIB_MAJOR == 1 && XCURSOR_LIB_MINOR < 1
0157     // These are the default paths Xcursor will scan for cursor themes
0158     QString path("~/.icons:/usr/share/icons:/usr/share/pixmaps:/usr/X11R6/lib/X11/icons");
0159 
0160     // If XCURSOR_PATH is set, use that instead of the default path
0161     char *xcursorPath = std::getenv("XCURSOR_PATH");
0162     if (xcursorPath)
0163         path = xcursorPath;
0164 #else
0165     // Get the search path from Xcursor
0166     QString path = XcursorLibraryPath();
0167     qCDebug(KCM_CURSORTHEME) << "XcursorLibraryPath:" << path;
0168 #endif
0169 
0170     // Separate the paths
0171     baseDirs = path.split(':', Qt::SkipEmptyParts);
0172 
0173     // Remove duplicates
0174     QMutableStringListIterator i(baseDirs);
0175     while (i.hasNext()) {
0176         const QString path = i.next();
0177         QMutableStringListIterator j(i);
0178         while (j.hasNext())
0179             if (j.next() == path)
0180                 j.remove();
0181     }
0182 
0183     // Expand all occurrences of ~/ to the home dir
0184     baseDirs.replaceInStrings(QRegularExpression("^~\\/"), QDir::home().path() + '/');
0185     return baseDirs;
0186 }
0187 
0188 bool CursorThemeModel::hasTheme(const QString &name) const
0189 {
0190     const uint hash = qHash(name);
0191 
0192     foreach (const CursorTheme *theme, list)
0193         if (theme->hash() == hash)
0194             return true;
0195 
0196     return false;
0197 }
0198 
0199 bool CursorThemeModel::isCursorTheme(const QString &theme, const int depth)
0200 {
0201     // Prevent infinite recursion
0202     if (depth > 10)
0203         return false;
0204 
0205     // Search each icon theme directory for 'theme'
0206     foreach (const QString &baseDir, searchPaths()) {
0207         QDir dir(baseDir);
0208         if (!dir.exists() || !dir.cd(theme))
0209             continue;
0210 
0211         // If there's a cursors subdir, we'll assume this is a cursor theme
0212         if (dir.exists(QStringLiteral("cursors")))
0213             return true;
0214 
0215         // If the theme doesn't have an index.theme file, it can't inherit any themes.
0216         if (!dir.exists(QStringLiteral("index.theme")))
0217             continue;
0218 
0219         // Open the index.theme file, so we can get the list of inherited themes
0220         KConfig config(dir.path() + "/index.theme", KConfig::NoGlobals);
0221         KConfigGroup cg(&config, u"Icon Theme"_s);
0222 
0223         // Recurse through the list of inherited themes, to check if one of them
0224         // is a cursor theme.
0225         const QStringList inherits = cg.readEntry("Inherits", QStringList());
0226         for (const QString &inherit : inherits) {
0227             // Avoid possible DoS
0228             if (inherit == theme)
0229                 continue;
0230 
0231             if (isCursorTheme(inherit, depth + 1))
0232                 return true;
0233         }
0234     }
0235 
0236     return false;
0237 }
0238 
0239 bool CursorThemeModel::handleDefault(const QDir &themeDir)
0240 {
0241     QFileInfo info(themeDir.path());
0242 
0243     // If "default" is a symlink
0244     if (info.isSymLink()) {
0245         QFileInfo target(info.symLinkTarget());
0246         if (target.exists() && (target.isDir() || target.isSymLink()))
0247             defaultName = target.fileName();
0248 
0249         return true;
0250     }
0251 
0252     // If there's no cursors subdir, or if it's empty
0253     if (!themeDir.exists(QStringLiteral("cursors")) || QDir(themeDir.path() + "/cursors").entryList(QDir::Files | QDir::NoDotAndDotDot).isEmpty()) {
0254         if (themeDir.exists(QStringLiteral("index.theme"))) {
0255             XCursorTheme theme(themeDir);
0256             if (!theme.inherits().isEmpty())
0257                 defaultName = theme.inherits().at(0);
0258         }
0259         return true;
0260     }
0261 
0262     defaultName = QStringLiteral("default");
0263     return false;
0264 }
0265 
0266 void CursorThemeModel::processThemeDir(const QDir &themeDir)
0267 {
0268     qCDebug(KCM_CURSORTHEME) << "Searching in" << themeDir;
0269     bool haveCursors = themeDir.exists(QStringLiteral("cursors"));
0270 
0271     // Special case handling of "default", since it's usually either a
0272     // symlink to another theme, or an empty theme that inherits another
0273     // theme.
0274     if (defaultName.isNull() && themeDir.dirName() == QLatin1String("default")) {
0275         if (handleDefault(themeDir))
0276             return;
0277     }
0278 
0279     // If the directory doesn't have a cursors subdir and lacks an
0280     // index.theme file it can't be a cursor theme.
0281     if (!themeDir.exists(QStringLiteral("index.theme")) && !haveCursors)
0282         return;
0283 
0284     // Create a cursor theme object for the theme dir
0285     XCursorTheme *theme = new XCursorTheme(themeDir);
0286 
0287     // Skip this theme if it's hidden.
0288     if (theme->isHidden()) {
0289         delete theme;
0290         return;
0291     }
0292 
0293     // If there's no cursors subdirectory we'll do a recursive scan
0294     // to check if the theme inherits a theme with one.
0295     if (!haveCursors) {
0296         bool foundCursorTheme = false;
0297 
0298         foreach (const QString &name, theme->inherits())
0299             if ((foundCursorTheme = isCursorTheme(name)))
0300                 break;
0301 
0302         if (!foundCursorTheme) {
0303             delete theme;
0304             return;
0305         }
0306     }
0307 
0308     // Append the theme to the list
0309     beginInsertRows(QModelIndex(), list.size(), list.size());
0310     list.append(theme);
0311     endInsertRows();
0312 }
0313 
0314 void CursorThemeModel::insertThemes()
0315 {
0316     // Scan each base dir for Xcursor themes and add them to the list.
0317     const QStringList paths{searchPaths()};
0318     qCDebug(KCM_CURSORTHEME) << "searchPaths:" << paths;
0319     for (const QString &baseDir : paths) {
0320         QDir dir(baseDir);
0321         if (!dir.exists())
0322             continue;
0323 
0324         // Process each subdir in the directory
0325         for (const auto list{dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)}; const QString &name : list) {
0326             // Don't process the theme if a theme with the same name already exists
0327             // in the list. Xcursor will pick the first one it finds in that case,
0328             // and since we use the same search order, the one Xcursor picks should
0329             // be the one already in the list.
0330             if (hasTheme(name) || !dir.cd(name))
0331                 continue;
0332 
0333             processThemeDir(dir);
0334             dir.cdUp(); // Return to the base dir
0335         }
0336     }
0337 
0338     // The theme Xcursor will end up using if no theme is configured
0339     if (defaultName.isNull() || !hasTheme(defaultName))
0340         defaultName = QStringLiteral("KDE_Classic");
0341 }
0342 
0343 bool CursorThemeModel::addTheme(const QDir &dir)
0344 {
0345     XCursorTheme *theme = new XCursorTheme(dir);
0346 
0347     // Don't add the theme to the list if it's hidden
0348     if (theme->isHidden()) {
0349         delete theme;
0350         return false;
0351     }
0352 
0353     // ### If the theme is hidden, the user will probably find it strange that it
0354     //     doesn't appear in the list view. There also won't be a way for the user
0355     //     to delete the theme using the KCM. Perhaps a warning about this should
0356     //     be issued, and the user be given a chance to undo the installation.
0357 
0358     // If an item with the same name already exists in the list,
0359     // we'll remove it before inserting the new one.
0360     for (int i = 0; i < list.count(); i++) {
0361         if (list.at(i)->hash() == theme->hash()) {
0362             removeTheme(index(i, 0));
0363             break;
0364         }
0365     }
0366 
0367     // Append the theme to the list
0368     beginInsertRows(QModelIndex(), rowCount(), rowCount());
0369     list.append(theme);
0370     endInsertRows();
0371 
0372     return true;
0373 }
0374 
0375 void CursorThemeModel::removeTheme(const QModelIndex &index)
0376 {
0377     if (!index.isValid())
0378         return;
0379 
0380     beginRemoveRows(QModelIndex(), index.row(), index.row());
0381     pendingDeletions.removeAll(list[index.row()]);
0382     delete list.takeAt(index.row());
0383     endRemoveRows();
0384 }