launcher: add favorite apps (#946)

* launcher: add favorite apps

Favorite apps always appear above non-favorite apps
Accepts regex, same logic as #920
Added the same regex logic to hidden apps
Added util file may need to be relocated

* addressed requested changes

* fix: Renamed newly added util singleton

Also added a null check to favorite icon loader in AppItem.qml

* controlCenter/launcherPane: added favorite apps

added icons to the app list to indicate if they are favorited/hidden
marking as favorite/hidden is desabled if the other is selected

* favouriteApps: renamed from favorite to favourite

Also disabled favorite/hidden switch for entries added as regex

* appDb: added notify and emit to favoriteApps

* controlCentre/Launcher: Fixed bug with favourite switch not enabling itself when no hiddenApps exist

Added a comment to explain the enabled state of the switches
icon loader is now a single loader rather than two, hidden icon has
priority

* spelling mistakes

* fixed warning

* formatting fixes
This commit is contained in:
Bora Gülerman 2026-02-19 13:26:10 +03:00 committed by GitHub
parent 3a7309294c
commit 40a2552830
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 180 additions and 32 deletions

View file

@ -562,6 +562,7 @@ default, you must create it manually.
"wallpapers": false "wallpapers": false
}, },
"showOnHover": false, "showOnHover": false,
"favouriteApps": [],
"hiddenApps": [] "hiddenApps": []
}, },
"lock": { "lock": {

View file

@ -297,6 +297,7 @@ Singleton {
enableDangerousActions: launcher.enableDangerousActions, enableDangerousActions: launcher.enableDangerousActions,
dragThreshold: launcher.dragThreshold, dragThreshold: launcher.dragThreshold,
vimKeybinds: launcher.vimKeybinds, vimKeybinds: launcher.vimKeybinds,
favouriteApps: launcher.favouriteApps,
hiddenApps: launcher.hiddenApps, hiddenApps: launcher.hiddenApps,
useFuzzy: { useFuzzy: {
apps: launcher.useFuzzy.apps, apps: launcher.useFuzzy.apps,

View file

@ -10,6 +10,7 @@ JsonObject {
property bool enableDangerousActions: false // Allow actions that can cause losing data, like shutdown, reboot and logout property bool enableDangerousActions: false // Allow actions that can cause losing data, like shutdown, reboot and logout
property int dragThreshold: 50 property int dragThreshold: 50
property bool vimKeybinds: false property bool vimKeybinds: false
property list<string> favouriteApps: []
property list<string> hiddenApps: [] property list<string> hiddenApps: []
property UseFuzzy useFuzzy: UseFuzzy {} property UseFuzzy useFuzzy: UseFuzzy {}
property Sizes sizes: Sizes {} property Sizes sizes: Sizes {}

View file

@ -24,6 +24,7 @@ Item {
property var selectedApp: root.session.launcher.active property var selectedApp: root.session.launcher.active
property bool hideFromLauncherChecked: false property bool hideFromLauncherChecked: false
property bool favouriteChecked: false
anchors.fill: parent anchors.fill: parent
@ -43,16 +44,14 @@ Item {
function updateToggleState() { function updateToggleState() {
if (!root.selectedApp) { if (!root.selectedApp) {
root.hideFromLauncherChecked = false; root.hideFromLauncherChecked = false;
root.favouriteChecked = false;
return; return;
} }
const appId = root.selectedApp.id || root.selectedApp.entry?.id; const appId = root.selectedApp.id || root.selectedApp.entry?.id;
if (Config.launcher.hiddenApps && Config.launcher.hiddenApps.length > 0) { root.hideFromLauncherChecked = Config.launcher.hiddenApps && Config.launcher.hiddenApps.length > 0 && Strings.testRegexList(Config.launcher.hiddenApps, appId);
root.hideFromLauncherChecked = Config.launcher.hiddenApps.includes(appId); root.favouriteChecked = Config.launcher.favouriteApps && Config.launcher.favouriteApps.length > 0 && Strings.testRegexList(Config.launcher.favouriteApps, appId);
} else {
root.hideFromLauncherChecked = false;
}
} }
function saveHiddenApps(isHidden) { function saveHiddenApps(isHidden) {
@ -83,6 +82,7 @@ Item {
id: allAppsDb id: allAppsDb
path: `${Paths.state}/apps.sqlite` path: `${Paths.state}/apps.sqlite`
favouriteApps: Config.launcher.favouriteApps
entries: DesktopEntries.applications.values entries: DesktopEntries.applications.values
} }
@ -286,6 +286,7 @@ Item {
id: appsListLoader id: appsListLoader
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
asynchronous: true
active: true active: true
sourceComponent: StyledListView { sourceComponent: StyledListView {
@ -306,6 +307,7 @@ Item {
required property var modelData required property var modelData
width: parent ? parent.width : 0 width: parent ? parent.width : 0
implicitHeight: 40
readonly property bool isSelected: root.selectedApp === modelData readonly property bool isSelected: root.selectedApp === modelData
@ -353,9 +355,34 @@ Item {
text: modelData.name || modelData.entry?.name || qsTr("Unknown") text: modelData.name || modelData.entry?.name || qsTr("Unknown")
font.pointSize: Appearance.font.size.normal font.pointSize: Appearance.font.size.normal
} }
Loader {
Layout.alignment: Qt.AlignVCenter
readonly property bool isHidden: modelData ? Strings.testRegexList(Config.launcher.hiddenApps, modelData.id) : false
readonly property bool isFav: modelData ? Strings.testRegexList(Config.launcher.favouriteApps, modelData.id) : false
active: isHidden || isFav
sourceComponent: isHidden ? hiddenIcon : (isFav ? favouriteIcon : null)
} }
implicitHeight: 40 Component {
id: hiddenIcon
MaterialIcon {
text: "visibility_off"
fill: 1
color: Colours.palette.m3primary
}
}
Component {
id: favouriteIcon
MaterialIcon {
text: "favorite"
fill: 1
color: Colours.palette.m3primary
}
}
}
} }
} }
} }
@ -440,13 +467,11 @@ Item {
onDisplayedAppChanged: { onDisplayedAppChanged: {
if (displayedApp) { if (displayedApp) {
const appId = displayedApp.id || displayedApp.entry?.id; const appId = displayedApp.id || displayedApp.entry?.id;
if (Config.launcher.hiddenApps && Config.launcher.hiddenApps.length > 0) { root.hideFromLauncherChecked = Config.launcher.hiddenApps && Config.launcher.hiddenApps.length > 0 && Strings.testRegexList(Config.launcher.hiddenApps, appId);
root.hideFromLauncherChecked = Config.launcher.hiddenApps.includes(appId); root.favouriteChecked = Config.launcher.favouriteApps && Config.launcher.favouriteApps.length > 0 && Strings.testRegexList(Config.launcher.favouriteApps, appId);
} else {
root.hideFromLauncherChecked = false;
}
} else { } else {
root.hideFromLauncherChecked = false; root.hideFromLauncherChecked = false;
root.favouriteChecked = false;
} }
} }
} }
@ -559,12 +584,51 @@ Item {
anchors.top: parent.top anchors.top: parent.top
spacing: Appearance.spacing.normal spacing: Appearance.spacing.normal
SwitchRow {
Layout.topMargin: Appearance.spacing.normal
visible: appDetailsLayout.displayedApp !== null
label: qsTr("Mark as favourite")
checked: root.favouriteChecked
// disabled if:
// * app is hidden
// * app isn't in favouriteApps array but marked as favourite anyway
// ^^^ This means that this app is favourited because of a regex check
// this button can not toggle regexed apps
enabled: appDetailsLayout.displayedApp !== null && !root.hideFromLauncherChecked && (Config.launcher.favouriteApps.indexOf(appDetailsLayout.displayedApp.id || appDetailsLayout.displayedApp.entry?.id) !== -1 || !root.favouriteChecked)
opacity: enabled ? 1 : 0.6
onToggled: checked => {
root.favouriteChecked = checked;
const app = appDetailsLayout.displayedApp;
if (app) {
const appId = app.id || app.entry?.id;
const favouriteApps = Config.launcher.favouriteApps ? [...Config.launcher.favouriteApps] : [];
if (checked) {
if (!favouriteApps.includes(appId)) {
favouriteApps.push(appId);
}
} else {
const index = favouriteApps.indexOf(appId);
if (index !== -1) {
favouriteApps.splice(index, 1);
}
}
Config.launcher.favouriteApps = favouriteApps;
Config.save();
}
}
}
SwitchRow { SwitchRow {
Layout.topMargin: Appearance.spacing.normal Layout.topMargin: Appearance.spacing.normal
visible: appDetailsLayout.displayedApp !== null visible: appDetailsLayout.displayedApp !== null
label: qsTr("Hide from launcher") label: qsTr("Hide from launcher")
checked: root.hideFromLauncherChecked checked: root.hideFromLauncherChecked
enabled: appDetailsLayout.displayedApp !== null // disabled if:
// * app is favourited
// * app isn't in hiddenApps array but marked as hidden anyway
// ^^^ This means that this app is hidden because of a regex check
// this button can not toggle regexed apps
enabled: appDetailsLayout.displayedApp !== null && !root.favouriteChecked && (Config.launcher.hiddenApps.indexOf(appDetailsLayout.displayedApp.id || appDetailsLayout.displayedApp.entry?.id) !== -1 || !root.hideFromLauncherChecked)
opacity: enabled ? 1 : 0.6
onToggled: checked => { onToggled: checked => {
root.hideFromLauncherChecked = checked; root.hideFromLauncherChecked = checked;
const app = appDetailsLayout.displayedApp; const app = appDetailsLayout.displayedApp;

View file

@ -4,6 +4,7 @@ import qs.components
import qs.components.containers import qs.components.containers
import qs.services import qs.services
import qs.config import qs.config
import qs.utils
import qs.modules.bar import qs.modules.bar
import Quickshell import Quickshell
import Quickshell.Wayland import Quickshell.Wayland
@ -18,20 +19,7 @@ Variants {
id: scope id: scope
required property ShellScreen modelData required property ShellScreen modelData
readonly property bool barDisabled: { readonly property bool barDisabled: Strings.testRegexList(Config.bar.excludedScreens, modelData.name)
const regexChecker = /^\^.*\$$/;
for (const filter of Config.bar.excludedScreens) {
// If filter is a regex
if (regexChecker.test(filter)) {
if ((new RegExp(filter)).test(modelData.name))
return true;
} else {
if (filter === modelData.name)
return true;
}
}
return false;
}
Exclusions { Exclusions {
screen: scope.modelData screen: scope.modelData

View file

@ -2,6 +2,7 @@ import "../services"
import qs.components import qs.components
import qs.services import qs.services
import qs.config import qs.config
import qs.utils
import Quickshell import Quickshell
import Quickshell.Widgets import Quickshell.Widgets
import QtQuick import QtQuick
@ -46,7 +47,7 @@ Item {
anchors.leftMargin: Appearance.spacing.normal anchors.leftMargin: Appearance.spacing.normal
anchors.verticalCenter: icon.verticalCenter anchors.verticalCenter: icon.verticalCenter
implicitWidth: parent.width - icon.width implicitWidth: parent.width - icon.width - favouriteIcon.width
implicitHeight: name.implicitHeight + comment.implicitHeight implicitHeight: name.implicitHeight + comment.implicitHeight
StyledText { StyledText {
@ -64,10 +65,24 @@ Item {
color: Colours.palette.m3outline color: Colours.palette.m3outline
elide: Text.ElideRight elide: Text.ElideRight
width: root.width - icon.width - Appearance.rounding.normal * 2 width: root.width - icon.width - favouriteIcon.width - Appearance.rounding.normal * 2
anchors.top: name.bottom anchors.top: name.bottom
} }
} }
Loader {
id: favouriteIcon
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
active: modelData && Strings.testRegexList(Config.launcher.favouriteApps, modelData.id)
sourceComponent: MaterialIcon {
text: "favorite"
fill: 1
color: Colours.palette.m3primary
}
}
} }
} }

View file

@ -72,6 +72,7 @@ Searcher {
id: appDb id: appDb
path: `${Paths.state}/apps.sqlite` path: `${Paths.state}/apps.sqlite`
entries: DesktopEntries.applications.values.filter(a => !Config.launcher.hiddenApps.includes(a.id)) favouriteApps: Config.launcher.favouriteApps
entries: DesktopEntries.applications.values.filter(a => !Strings.testRegexList(Config.launcher.hiddenApps, a.id))
} }
} }

View file

@ -162,6 +162,39 @@ void AppDb::setEntries(const QObjectList& entries) {
m_timer->start(); m_timer->start();
} }
QStringList AppDb::favouriteApps() const {
return m_favouriteApps;
}
void AppDb::setFavouriteApps(const QStringList& favApps) {
if (m_favouriteApps == favApps) {
return;
}
m_favouriteApps = favApps;
emit favouriteAppsChanged();
m_favouriteAppsRegex.clear();
m_favouriteAppsRegex.reserve(m_favouriteApps.size());
for (const QString& item : std::as_const(m_favouriteApps)) {
const QRegularExpression re(regexifyString(item));
if (re.isValid()) {
m_favouriteAppsRegex << re;
} else {
qWarning() << "AppDb::setFavouriteApps: Regular expression is not valid: " << re.pattern();
}
}
emit appsChanged();
}
QString AppDb::regexifyString(const QString& original) const {
if (original.startsWith('^') && original.endsWith('$'))
return original;
const QString escaped = QRegularExpression::escape(original);
return QStringLiteral("^%1$").arg(escaped);
}
QQmlListProperty<AppEntry> AppDb::apps() { QQmlListProperty<AppEntry> AppDb::apps() {
return QQmlListProperty<AppEntry>(this, &getSortedApps()); return QQmlListProperty<AppEntry>(this, &getSortedApps());
} }
@ -192,7 +225,12 @@ void AppDb::incrementFrequency(const QString& id) {
QList<AppEntry*>& AppDb::getSortedApps() const { QList<AppEntry*>& AppDb::getSortedApps() const {
m_sortedApps = m_apps.values(); m_sortedApps = m_apps.values();
std::sort(m_sortedApps.begin(), m_sortedApps.end(), [](AppEntry* a, AppEntry* b) { std::sort(m_sortedApps.begin(), m_sortedApps.end(), [this](AppEntry* a, AppEntry* b) {
bool aIsFav = isFavourite(a);
bool bIsFav = isFavourite(b);
if (aIsFav != bIsFav) {
return aIsFav;
}
if (a->frequency() != b->frequency()) { if (a->frequency() != b->frequency()) {
return a->frequency() > b->frequency(); return a->frequency() > b->frequency();
} }
@ -201,6 +239,15 @@ QList<AppEntry*>& AppDb::getSortedApps() const {
return m_sortedApps; return m_sortedApps;
} }
bool AppDb::isFavourite(const AppEntry* app) const {
for (const QRegularExpression& re : m_favouriteAppsRegex) {
if (re.match(app->id()).hasMatch()) {
return true;
}
}
return false;
}
quint32 AppDb::getFrequency(const QString& id) const { quint32 AppDb::getFrequency(const QString& id) const {
auto db = QSqlDatabase::database(m_uuid); auto db = QSqlDatabase::database(m_uuid);
QSqlQuery query(db); QSqlQuery query(db);

View file

@ -4,6 +4,7 @@
#include <qobject.h> #include <qobject.h>
#include <qqmlintegration.h> #include <qqmlintegration.h>
#include <qqmllist.h> #include <qqmllist.h>
#include <qregularexpression.h>
#include <qtimer.h> #include <qtimer.h>
namespace caelestia { namespace caelestia {
@ -66,6 +67,7 @@ class AppDb : public QObject {
Q_PROPERTY(QString uuid READ uuid CONSTANT) Q_PROPERTY(QString uuid READ uuid CONSTANT)
Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED) Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED)
Q_PROPERTY(QObjectList entries READ entries WRITE setEntries NOTIFY entriesChanged REQUIRED) Q_PROPERTY(QObjectList entries READ entries WRITE setEntries NOTIFY entriesChanged REQUIRED)
Q_PROPERTY(QStringList favouriteApps READ favouriteApps WRITE setFavouriteApps NOTIFY favouriteAppsChanged REQUIRED)
Q_PROPERTY(QQmlListProperty<caelestia::AppEntry> apps READ apps NOTIFY appsChanged) Q_PROPERTY(QQmlListProperty<caelestia::AppEntry> apps READ apps NOTIFY appsChanged)
public: public:
@ -79,6 +81,9 @@ public:
[[nodiscard]] QObjectList entries() const; [[nodiscard]] QObjectList entries() const;
void setEntries(const QObjectList& entries); void setEntries(const QObjectList& entries);
[[nodiscard]] QStringList favouriteApps() const;
void setFavouriteApps(const QStringList& favApps);
[[nodiscard]] QQmlListProperty<AppEntry> apps(); [[nodiscard]] QQmlListProperty<AppEntry> apps();
Q_INVOKABLE void incrementFrequency(const QString& id); Q_INVOKABLE void incrementFrequency(const QString& id);
@ -86,6 +91,7 @@ public:
signals: signals:
void pathChanged(); void pathChanged();
void entriesChanged(); void entriesChanged();
void favouriteAppsChanged();
void appsChanged(); void appsChanged();
private: private:
@ -94,10 +100,14 @@ private:
const QString m_uuid; const QString m_uuid;
QString m_path; QString m_path;
QObjectList m_entries; QObjectList m_entries;
QStringList m_favouriteApps; // unedited string list from qml
QList<QRegularExpression> m_favouriteAppsRegex; // pre-regexified m_favouriteApps list
QHash<QString, AppEntry*> m_apps; QHash<QString, AppEntry*> m_apps;
mutable QList<AppEntry*> m_sortedApps; mutable QList<AppEntry*> m_sortedApps;
QString regexifyString(const QString& original) const;
QList<AppEntry*>& getSortedApps() const; QList<AppEntry*>& getSortedApps() const;
bool isFavourite(const AppEntry* app) const;
quint32 getFrequency(const QString& id) const; quint32 getFrequency(const QString& id) const;
void updateAppFrequencies(); void updateAppFrequencies();
void updateApps(); void updateApps();

20
utils/Strings.qml Normal file
View file

@ -0,0 +1,20 @@
pragma Singleton
import Quickshell
Singleton {
function testRegexList(filterList: list<string>, target: string): bool {
const regexChecker = /^\^.*\$$/;
for (const filter of filterList) {
// If filter is a regex
if (regexChecker.test(filter)) {
if ((new RegExp(filter)).test(target))
return true;
} else {
if (filter === target)
return true;
}
}
return false;
}
}