Wallpaper fav system

This commit is contained in:
tuibird
2026-02-12 11:34:55 +13:00
parent 95313e1d24
commit dcb661e7f3
7 changed files with 461 additions and 41 deletions
+2
View File
@@ -1791,6 +1791,8 @@
"apikey-placeholder": "Enter your Wallhaven API Key",
"apply-all-monitors-description": "Apply the selected wallpaper to all monitors.",
"apply-all-monitors-label": "Apply to all monitors",
"color-extraction-disabled": "Enable wallpaper color extraction",
"color-extraction-enabled": "Enable predetermined colors",
"categories-anime": "Anime",
"categories-label": "Categories",
"categories-people": "People",
+39
View File
@@ -0,0 +1,39 @@
import QtQuick
QtObject {
id: root
// Add default keybinds (1-6) to session menu power options if none are defined
function migrate(adapter, logger, rawJson) {
logger.i("Settings", "Migrating settings to v53");
const powerOptions = rawJson?.sessionMenu?.powerOptions;
if (!powerOptions || !Array.isArray(powerOptions))
return true;
// Check if any power option has a keybind defined
const hasAnyKeybind = powerOptions.some(opt => opt.keybind && opt.keybind !== "");
if (hasAnyKeybind)
return true;
// No keybinds defined — apply defaults matching the action order
const defaultKeybinds = {
"lock": "1",
"suspend": "2",
"hibernate": "3",
"reboot": "4",
"logout": "5",
"shutdown": "6"
};
for (let i = 0; i < powerOptions.length; i++) {
const action = powerOptions[i].action;
if (defaultKeybinds[action]) {
adapter.sessionMenu.powerOptions[i].keybind = defaultKeybinds[action];
logger.i("Settings", "Set keybind '" + defaultKeybinds[action] + "' for session menu action: " + action);
}
}
return true;
}
}
+3 -1
View File
@@ -24,7 +24,8 @@ QtObject {
47: migration47Component,
48: migration48Component,
49: migration49Component,
50: migration50Component
50: migration50Component,
53: migration53Component
})
// Migration components
@@ -46,4 +47,5 @@ QtObject {
property Component migration48Component: Migration48 {}
property Component migration49Component: Migration49 {}
property Component migration50Component: Migration50 {}
property Component migration53Component: Migration53 {}
}
+3 -1
View File
@@ -25,7 +25,7 @@ Singleton {
- Default cache directory: ~/.cache/noctalia
*/
readonly property alias data: adapter // Used to access via Settings.data.xxx.yyy
readonly property int settingsVersion: 52
readonly property int settingsVersion: 53
readonly property bool isDebug: Quickshell.env("NOCTALIA_DEBUG") === "1"
readonly property string shellName: "noctalia"
readonly property string configDir: Quickshell.env("NOCTALIA_CONFIG_DIR") || (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/" + shellName + "/"
@@ -393,6 +393,8 @@ Singleton {
property string wallhavenResolutionHeight: ""
property string sortOrder: "name" // "name", "name_desc", "date", "date_desc", "random"
property list<var> favorites: []
// Format: [{ "path": "/path/to/wallpaper.jpg", "colorScheme": "...", "darkMode": true, "useWallpaperColors": true, "generationMethod": "tonal-spot" }]
}
// applauncher
+252 -30
View File
@@ -114,8 +114,9 @@ SmartPanel {
let view = contentItem.screenRepeater.itemAt(contentItem.currentScreenIndex);
if (view?.gridView?.hasActiveFocus) {
let gridView = view.gridView;
if (gridView.currentIndex >= 0 && gridView.currentIndex < gridView.model.length) {
view.selectItem(gridView.model[gridView.currentIndex]);
if (gridView.currentIndex >= 0 && gridView.currentIndex < gridView.model.count) {
var item = gridView.model.get(gridView.currentIndex);
view.selectItem(item.path, item.isDirectory);
}
}
}
@@ -437,17 +438,72 @@ SmartPanel {
}
}
NIconButton {
icon: Settings.data.colorSchemes.darkMode ? "moon" : "sun"
tooltipText: Settings.data.colorSchemes.darkMode ? I18n.tr("tooltips.switch-to-light-mode") : I18n.tr("tooltips.switch-to-dark-mode")
baseSize: Style.baseWidgetSize * 0.8
onClicked: Settings.data.colorSchemes.darkMode = !Settings.data.colorSchemes.darkMode
}
NIconButton {
icon: "color-swatch"
tooltipText: Settings.data.colorSchemes.useWallpaperColors
? I18n.tr("wallpaper.panel.color-extraction-enabled")
: I18n.tr("wallpaper.panel.color-extraction-disabled")
baseSize: Style.baseWidgetSize * 0.8
onClicked: {
Settings.data.colorSchemes.useWallpaperColors = !Settings.data.colorSchemes.useWallpaperColors;
if (Settings.data.colorSchemes.useWallpaperColors) {
AppThemeService.generate();
} else {
ColorSchemeService.setPredefinedScheme(Settings.data.colorSchemes.predefinedScheme);
}
}
}
NComboBox {
visible: Settings.data.colorSchemes.useWallpaperColors
id: colorSchemeComboBox
Layout.fillWidth: false
Layout.minimumWidth: 200
minimumWidth: 200
model: TemplateProcessor.schemeTypes
currentKey: Settings.data.colorSchemes.generationMethod
property bool _initialized: false
property bool _userChanging: false
Component.onCompleted: Qt.callLater(() => { _initialized = true; })
model: Settings.data.colorSchemes.useWallpaperColors
? TemplateProcessor.schemeTypes
: ColorSchemeService.schemes.map(s => ({
"key": ColorSchemeService.getBasename(s),
"name": ColorSchemeService.getBasename(s)
}))
currentKey: Settings.data.colorSchemes.useWallpaperColors
? Settings.data.colorSchemes.generationMethod
: Settings.data.colorSchemes.predefinedScheme
onCurrentKeyChanged: {
if (!_initialized) return;
if (_userChanging) {
_userChanging = false;
return;
}
schemeGlowAnimation.restart();
}
onSelected: key => {
Settings.data.colorSchemes.generationMethod = key;
AppThemeService.generate();
_userChanging = true;
if (Settings.data.colorSchemes.useWallpaperColors) {
Settings.data.colorSchemes.generationMethod = key;
AppThemeService.generate();
} else {
ColorSchemeService.setPredefinedScheme(key);
}
Qt.callLater(() => { _userChanging = false; });
}
SequentialAnimation {
id: schemeGlowAnimation
NumberAnimation { target: colorSchemeComboBox; property: "opacity"; to: 0.3; duration: Style.animationSlow; easing.type: Easing.OutCubic }
NumberAnimation { target: colorSchemeComboBox; property: "opacity"; to: 1.0; duration: Style.animationSlow; easing.type: Easing.InCubic }
}
}
NComboBox {
@@ -575,12 +631,30 @@ SmartPanel {
property var wallpapersWithNames: [] // Cached basenames for files
property var directoriesList: [] // List of directories in browse mode
// ListModel for the grid — enables animated reordering via move()
ListModel { id: wallpaperModel }
// Browse mode properties
property string currentBrowsePath: WallpaperService.getCurrentBrowsePath(targetScreen?.name ?? "")
property bool isBrowseMode: Settings.data.wallpaper.viewMode === "browse"
// Expose updateFiltered as a proper function property
function updateFiltered() {
// Sort favorites to the top (only for non-directory items)
function sortFavoritesToTop(items) {
var favorited = [];
var nonFavorited = [];
for (var i = 0; i < items.length; i++) {
if (!items[i].isDirectory && WallpaperService.isFavorite(items[i].path)) {
favorited.push(items[i]);
} else {
nonFavorited.push(items[i]);
}
}
return favorited.concat(nonFavorited);
}
// Rebuild filteredItems and sync to wallpaperModel (full replacement, no animation).
// When skipSync is true the caller will animate the model itself.
function updateFiltered(skipSync) {
var combinedItems = [];
// In browse mode, add directories first
@@ -604,9 +678,12 @@ SmartPanel {
});
}
combinedItems = sortFavoritesToTop(combinedItems);
// Apply filter if text is present
if (!panelContent.filterText || panelContent.filterText.trim().length === 0) {
filteredItems = combinedItems;
if (!skipSync) syncModel();
return;
}
@@ -614,10 +691,56 @@ SmartPanel {
"key": 'name',
"limit": 200
});
// Map back to item list
filteredItems = results.map(function (r) {
var filtered = results.map(function (r) {
return r.obj;
});
filteredItems = sortFavoritesToTop(filtered);
if (!skipSync) syncModel();
}
// Copy filteredItems into the ListModel (full rebuild, no animation).
function syncModel() {
wallpaperModel.clear();
for (var i = 0; i < filteredItems.length; i++) {
wallpaperModel.append(filteredItems[i]);
}
wallpaperGridView.currentIndex = -1;
wallpaperGridView.positionViewAtBeginning();
}
// Animate a single item moving to its new position after a favorite toggle.
function handleFavoriteMove(path) {
// Find where the item currently sits in the model
var fromIndex = -1;
for (var i = 0; i < wallpaperModel.count; i++) {
if (wallpaperModel.get(i).path === path) {
fromIndex = i;
break;
}
}
if (fromIndex === -1) return;
// Find where it should be in the freshly-computed filteredItems
var toIndex = -1;
for (var j = 0; j < filteredItems.length; j++) {
if (filteredItems[j].path === path) {
toIndex = j;
break;
}
}
if (toIndex === -1 || fromIndex === toIndex) return;
wallpaperGridView.animateMovement = true;
wallpaperModel.move(fromIndex, toIndex, 1);
animateMovementResetTimer.restart();
}
// Turn off move animations shortly after the move completes so that
// non-favorite model rebuilds (sort, filter, navigation) don't animate.
Timer {
id: animateMovementResetTimer
interval: Style.animationNormal + 50
onTriggered: wallpaperGridView.animateMovement = false
}
Component.onCompleted: {
@@ -651,6 +774,10 @@ SmartPanel {
refreshWallpaperScreenData();
}
}
function onFavoritesChanged(path) {
updateFiltered(true); // recompute filteredItems but skip full model rebuild
handleFavoriteMove(path); // animate the item to its new position
}
}
function refreshWallpaperScreenData() {
@@ -680,13 +807,13 @@ SmartPanel {
}
}
function selectItem(item) {
if (item.isDirectory) {
WallpaperService.setBrowsePath(targetScreen.name, item.path);
} else if (Settings.data.wallpaper.setWallpaperOnAllMonitors) {
WallpaperService.changeWallpaper(item.path, undefined);
function selectItem(path, isDirectory) {
if (isDirectory) {
WallpaperService.setBrowsePath(targetScreen.name, path);
} else {
WallpaperService.changeWallpaper(item.path, targetScreen.name);
var screen = Settings.data.wallpaper.setWallpaperOnAllMonitors ? undefined : targetScreen.name;
WallpaperService.changeWallpaper(path, screen);
WallpaperService.applyFavoriteTheme(path, screen);
}
}
@@ -864,13 +991,7 @@ SmartPanel {
highlightFollowsCurrentItem: false
currentIndex: -1
model: filteredItems
onModelChanged: {
// Reset selection and scroll position when model changes
currentIndex = -1;
positionViewAtBeginning();
}
model: wallpaperModel
Component.onCompleted: {
positionViewAtBeginning();
@@ -923,10 +1044,11 @@ SmartPanel {
anchors.fill: parent
anchors.margins: Style.marginXS
property string wallpaperPath: modelData.path ?? ""
property bool isDirectory: modelData.isDirectory ?? false
property string wallpaperPath: model.path ?? ""
property bool isDirectory: model.isDirectory ?? false
property bool isSelected: !isDirectory && (wallpaperPath === currentWallpaper)
property string filename: modelData.name ?? wallpaperPath.split('/').pop()
property bool isFavorited: !isDirectory && WallpaperService.isFavorite(wallpaperPath)
property string filename: model.name ?? wallpaperPath.split('/').pop()
property string cachedPath: ""
spacing: Style.marginXS
@@ -1043,6 +1165,106 @@ SmartPanel {
}
}
// Favorite star button (top-left)
Rectangle {
anchors.top: parent.top
anchors.left: parent.left
anchors.margins: Style.marginS
width: 28
height: 28
radius: width / 2
visible: !wallpaperItem.isDirectory && (wallpaperItem.isFavorited || hoverHandler.hovered || wallpaperGridView.currentIndex === index)
color: {
if (wallpaperItem.isFavorited)
return starHoverHandler.hovered ? Color.mHover : Color.mPrimary;
return starHoverHandler.hovered ? Color.mSurfaceVariant : Color.mSurface;
}
opacity: wallpaperItem.isFavorited || starHoverHandler.hovered ? 1.0 : 0.7
z: 5
Behavior on color {
ColorAnimation { duration: Style.animationFast }
}
Behavior on opacity {
NumberAnimation { duration: Style.animationFast }
}
NIcon {
icon: wallpaperItem.isFavorited ? "star-filled" : "star"
pointSize: Style.fontSizeM
color: {
if (wallpaperItem.isFavorited)
return starHoverHandler.hovered ? Color.mOnHover : Color.mOnPrimary;
return starHoverHandler.hovered ? Color.mOnSurface : Color.mOnSurfaceVariant;
}
anchors.centerIn: parent
}
HoverHandler {
id: starHoverHandler
}
TapHandler {
onTapped: {
WallpaperService.toggleFavorite(wallpaperItem.wallpaperPath);
}
}
}
// Palette color dots (bottom-center, favorites only)
Row {
id: paletteRow
anchors.bottom: img.bottom
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottomMargin: Style.marginS
spacing: Style.marginXS
z: 5
visible: wallpaperItem.isFavorited && paletteRow.colors.length > 0
property int diameter: 25 * Style.uiScaleRatio
property int _favRevision: 0
property var favData: { _favRevision; return WallpaperService.getFavorite(wallpaperItem.wallpaperPath); }
property var colors: favData && favData.paletteColors ? favData.paletteColors : []
property bool isDark: favData ? favData.darkMode : false
Connections {
target: WallpaperService
function onFavoriteDataUpdated(path) {
if (path === wallpaperItem.wallpaperPath) paletteRow._favRevision++;
}
}
// Dark/light mode indicator
Rectangle {
width: paletteRow.diameter
height: paletteRow.diameter
radius: width * 0.5
color: Color.mSurface
border.color: Color.mShadow
border.width: Style.borderS
NIcon {
icon: paletteRow.isDark ? "moon" : "sun"
pointSize: parent.width * 0.45
color: Color.mOnSurface
anchors.centerIn: parent
}
}
Repeater {
model: paletteRow.colors
Rectangle {
width: paletteRow.diameter
height: paletteRow.diameter
radius: width * 0.5
color: modelData
border.color: Color.mShadow
border.width: Style.borderS
}
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
@@ -1066,7 +1288,7 @@ SmartPanel {
onTapped: {
wallpaperGridView.forceActiveFocus();
wallpaperGridView.currentIndex = index;
selectItem(modelData);
selectItem(wallpaperItem.wallpaperPath, wallpaperItem.isDirectory);
}
}
}
@@ -1093,7 +1315,7 @@ SmartPanel {
radius: Style.radiusM
border.color: Color.mOutline
border.width: Style.borderS
visible: (filteredItems.length === 0 && !WallpaperService.scanning) || WallpaperService.scanning
visible: (wallpaperModel.count === 0 && !WallpaperService.scanning) || WallpaperService.scanning
Layout.fillWidth: true
Layout.preferredHeight: 130
@@ -1107,7 +1329,7 @@ SmartPanel {
ColumnLayout {
anchors.fill: parent
visible: filteredItems.length === 0 && !WallpaperService.scanning
visible: wallpaperModel.count === 0 && !WallpaperService.scanning
Item {
Layout.fillHeight: true
}
+147 -9
View File
@@ -4,6 +4,7 @@ import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Theming
import qs.Services.UI
Singleton {
@@ -345,11 +346,14 @@ Singleton {
Settings.data.wallpaper.useSolidColor = false;
}
// Save current favorite color schemes before switching away.
// This must happen before applyFavoriteTheme (called by the UI)
// overwrites the settings that _createFavoriteEntry reads.
_saveOutgoingFavorites(path, screenName);
if (screenName !== undefined) {
_setWallpaper(screenName, path);
} else {
// If no screenName specified change for all screens
// Merge connected screens and cached screens to include disconnected monitors
var allScreenNames = new Set(Object.keys(currentWallpapers));
for (var i = 0; i < Quickshell.screens.length; i++) {
allScreenNames.add(Quickshell.screens[i].name);
@@ -358,6 +362,23 @@ Singleton {
}
}
// -------------------------------------------------------------------
// Save the color scheme of any favorited wallpapers that are about
// to be replaced, while the current settings still reflect them.
function _saveOutgoingFavorites(newPath, screenName) {
var outgoing = screenName !== undefined
? [currentWallpapers[screenName]]
: Object.values(currentWallpapers);
var unique = [...new Set(outgoing)];
unique.forEach(function(path) {
if (path && path !== newPath && isFavorite(path)) {
updateFavoriteColorScheme(path);
}
});
}
// -------------------------------------------------------------------
function _setWallpaper(screenName, path) {
if (path === "" || path === undefined) {
@@ -369,14 +390,8 @@ Singleton {
return;
}
//Logger.i("Wallpaper", "setWallpaper on", screenName, ": ", path)
// Check if wallpaper actually changed
var oldPath = currentWallpapers[screenName] || "";
var wallpaperChanged = (oldPath !== path);
if (!wallpaperChanged) {
// No change needed
if (oldPath === path) {
return;
}
@@ -903,6 +918,129 @@ Singleton {
_scanDirectoryInternal(screenName, directory, true, true, null);
}
// -------------------------------------------------------------------
// Favorites
// -------------------------------------------------------------------
readonly property int _favoriteNotFound: -1
// -------------------------------------------------------------------
function _findFavoriteIndex(path) {
var favorites = Settings.data.wallpaper.favorites;
for (var i = 0; i < favorites.length; i++) {
if (favorites[i].path === path) {
return i;
}
}
return _favoriteNotFound;
}
// -------------------------------------------------------------------
function _createFavoriteEntry(path) {
return {
"path": path,
"colorScheme": Settings.data.colorSchemes.predefinedScheme,
"darkMode": Settings.data.colorSchemes.darkMode,
"useWallpaperColors": Settings.data.colorSchemes.useWallpaperColors,
"generationMethod": Settings.data.colorSchemes.generationMethod,
"paletteColors": [
Color.mPrimary.toString(),
Color.mSecondary.toString(),
Color.mTertiary.toString(),
Color.mError.toString()
]
};
}
// -------------------------------------------------------------------
function isFavorite(path) {
return _findFavoriteIndex(path) !== _favoriteNotFound;
}
// -------------------------------------------------------------------
function getFavorite(path) {
var favoriteIndex = _findFavoriteIndex(path);
if (favoriteIndex === _favoriteNotFound)
return null;
return Settings.data.wallpaper.favorites[favoriteIndex];
}
// -------------------------------------------------------------------
function toggleFavorite(path) {
var favorites = Settings.data.wallpaper.favorites.slice();
var existingIndex = _findFavoriteIndex(path);
if (existingIndex !== _favoriteNotFound) {
favorites.splice(existingIndex, 1);
Logger.d("Wallpaper", "Removed favorite:", path);
} else {
favorites.push(_createFavoriteEntry(path));
Logger.d("Wallpaper", "Added favorite:", path);
}
Settings.data.wallpaper.favorites = favorites;
favoritesChanged(path);
}
// -------------------------------------------------------------------
function applyFavoriteTheme(path, screenName) {
// Only apply theme if the wallpaper is on the monitor driving colors
var effectiveMonitor = Settings.data.colorSchemes.monitorForColors;
if (effectiveMonitor === "" || effectiveMonitor === undefined) {
effectiveMonitor = Quickshell.screens.length > 0 ? Quickshell.screens[0].name : "";
}
if (screenName !== undefined && screenName !== effectiveMonitor) {
return;
}
var favorite = getFavorite(path);
if (!favorite)
return;
// Only update settings — generation is handled by the wallpaperChanged
// signal in AppThemeService, which fires after changeWallpaper().
Settings.data.colorSchemes.useWallpaperColors = favorite.useWallpaperColors;
Settings.data.colorSchemes.predefinedScheme = favorite.colorScheme;
Settings.data.colorSchemes.generationMethod = favorite.generationMethod;
Settings.data.colorSchemes.darkMode = favorite.darkMode;
}
// -------------------------------------------------------------------
function updateFavoriteColorScheme(path) {
var existingIndex = _findFavoriteIndex(path);
if (existingIndex === _favoriteNotFound)
return;
var favorites = Settings.data.wallpaper.favorites.slice();
favorites[existingIndex] = _createFavoriteEntry(path);
Settings.data.wallpaper.favorites = favorites;
Logger.d("Wallpaper", "Updated color scheme for favorite:", path);
favoriteDataUpdated(path);
}
signal favoritesChanged(string path)
signal favoriteDataUpdated(string path)
// Auto-update favorite palette colors when theme colors finish transitioning
Connections {
target: Color
function onIsTransitioningChanged() {
if (!Color.isTransitioning) {
_updateCurrentWallpaperFavorites();
}
}
}
function _updateCurrentWallpaperFavorites() {
var effectiveMonitor = Settings.data.colorSchemes.monitorForColors;
if (effectiveMonitor === "" || effectiveMonitor === undefined) {
effectiveMonitor = Quickshell.screens.length > 0 ? Quickshell.screens[0].name : "";
}
var wp = getWallpaper(effectiveMonitor);
if (wp && isFavorite(wp)) {
updateFavoriteColorScheme(wp);
}
}
// -------------------------------------------------------------------
// -------------------------------------------------------------------
// -------------------------------------------------------------------
+15
View File
@@ -77,6 +77,9 @@ Item {
property alias verticalVelocity: gridView.verticalVelocity
property alias reuseItems: gridView.reuseItems
// Animate items when the model is reordered (e.g. ListModel.move())
property bool animateMovement: false
// Scroll speed multiplier for mouse wheel (1.0 = default, higher = faster)
property real wheelScrollMultiplier: 2.0
@@ -248,6 +251,18 @@ Item {
anchors.fill: parent
anchors.rightMargin: root.reserveScrollbarSpace ? root.handleWidth + Style.marginXS : 0
move: root.animateMovement ? moveTransitionImpl : null
displaced: root.animateMovement ? displacedTransitionImpl : null
Transition {
id: moveTransitionImpl
NumberAnimation { properties: "x,y"; duration: Style.animationNormal; easing.type: Easing.InOutQuad }
}
Transition {
id: displacedTransitionImpl
NumberAnimation { properties: "x,y"; duration: Style.animationNormal; easing.type: Easing.InOutQuad }
}
// Enable clipping to keep content within bounds
clip: true