feat: add wallpaper selector menu (#1820)

This commit is contained in:
end-4
2025-08-27 21:56:50 +07:00
committed by GitHub
21 changed files with 1216 additions and 3 deletions
@@ -350,6 +350,10 @@ Singleton {
property real baseVerticalBarWidth: 46
property real verticalBarWidth: Config.options.bar.cornerStyle === 1 ?
(baseVerticalBarWidth + root.sizes.hyprlandGapsOut * 2) : baseVerticalBarWidth
property real wallpaperSelectorWidth: 1200
property real wallpaperSelectorHeight: 690
property real wallpaperSelectorItemMargins: 8
property real wallpaperSelectorItemPadding: 6
}
syntaxHighlightingTheme: root.m3colors.darkmode ? "Monokai" : "ayu Light"
@@ -8,12 +8,17 @@ import Quickshell
Singleton {
// XDG Dirs, with "file://"
readonly property string home: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0]
readonly property string config: StandardPaths.standardLocations(StandardPaths.ConfigLocation)[0]
readonly property string state: StandardPaths.standardLocations(StandardPaths.StateLocation)[0]
readonly property string cache: StandardPaths.standardLocations(StandardPaths.CacheLocation)[0]
readonly property string pictures: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0]
readonly property string genericCache: StandardPaths.standardLocations(StandardPaths.GenericCacheLocation)[0]
readonly property string documents: StandardPaths.standardLocations(StandardPaths.DocumentsLocation)[0]
readonly property string downloads: StandardPaths.standardLocations(StandardPaths.DownloadLocation)[0]
readonly property string pictures: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0]
readonly property string music: StandardPaths.standardLocations(StandardPaths.MusicLocation)[0]
readonly property string videos: StandardPaths.standardLocations(StandardPaths.MoviesLocation)[0]
// Other dirs used by the shell, without "file://"
property string assetsPath: Quickshell.shellPath("assets")
property string scriptPath: Quickshell.shellPath("scripts")
@@ -0,0 +1,31 @@
pragma Singleton
import Quickshell
Singleton {
// Formats
readonly property list<string> validImageTypes: ["jpeg", "png", "webp", "tiff", "svg"]
readonly property list<string> validImageExtensions: ["jpg", "jpeg", "png", "webp", "tif", "tiff", "svg"]
function isValidImageByName(name: string): bool {
return validImageExtensions.some(t => name.endsWith(`.${t}`));
}
// Thumbnails
// https://specifications.freedesktop.org/thumbnail-spec/latest/directory.html
readonly property var thumbnailSizes: ({
"normal": 128,
"large": 256,
"x-large": 512,
"xx-large": 1024
})
function thumbnailSizeNameForDimensions(width: int, height: int): string {
const sizeNames = Object.keys(thumbnailSizes);
for(let i = 0; i < sizeNames.length; i++) {
const sizeName = sizeNames[i];
const maxSize = thumbnailSizes[sizeName];
if (width <= maxSize && height <= maxSize) return sizeName;
}
return "xx-large";
}
}
@@ -0,0 +1,55 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.modules.common
import qs.modules.common.functions
/**
* Thumbnail image. It currently generates to the right place at the right size, but does not handle metadata/maintenance on modification.
* See Freedesktop's spec: https://specifications.freedesktop.org/thumbnail-spec/thumbnail-spec-latest.html
*/
Image {
id: root
property bool generateThumbnail: true
required property string sourcePath
property string thumbnailSizeName: Images.thumbnailSizeNameForDimensions(sourceSize.width, sourceSize.height)
property string thumbnailPath: {
if (sourcePath.length == 0) return;
const resolvedUrl = Qt.resolvedUrl(sourcePath);
const md5Hash = Qt.md5(resolvedUrl);
return `${Directories.genericCache}/thumbnails/${thumbnailSizeName}/${md5Hash}.png`;
}
source: thumbnailPath
asynchronous: true
cache: false
smooth: true
mipmap: false
opacity: status === Image.Ready ? 1 : 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
onSourceSizeChanged: {
if (!root.generateThumbnail) return;
thumbnailGeneration.running = false;
thumbnailGeneration.running = true;
}
Process {
id: thumbnailGeneration
command: {
const maxSize = Images.thumbnailSizes[root.thumbnailSizeName];
return ["bash", "-c",
`[ -f '${FileUtils.trimFileProtocol(root.thumbnailPath)}' ] && exit 0 || { magick '${root.sourcePath}' -resize ${maxSize}x${maxSize} '${FileUtils.trimFileProtocol(root.thumbnailPath)}' && exit 1; }`
]
}
onExited: (exitCode, exitStatus) => {
if (exitCode === 1) { // Force reload if thumbnail had to be generated
root.source = "";
root.source = root.thumbnailPath; // Force reload
}
}
}
}
@@ -24,6 +24,20 @@ Singleton {
return trimmed.split(/[\\/]/).pop();
}
/**
* Extracts the folder name from a directory path
* @param {string} str
* @returns {string}
*/
function folderNameForPath(str) {
if (typeof str !== "string") return "";
const trimmed = trimFileProtocol(str);
// Remove trailing slash if present
const noTrailing = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
if (!noTrailing) return "";
return noTrailing.split(/[\\/]/).pop();
}
/**
* Removes the file extension from a file path or name
* @param {string} str
@@ -38,4 +52,18 @@ Singleton {
}
return trimmed;
}
/**
* Returns the parent directory of a given file path
* @param {string} str
* @returns {string}
*/
function parentDirectory(str) {
if (typeof str !== "string") return "";
const trimmed = trimFileProtocol(str);
const parts = trimmed.split(/[\\/]/);
if (parts.length <= 1) return "";
parts.pop();
return parts.join("/");
}
}
@@ -0,0 +1,120 @@
import QtQuick
import QtQuick.Layouts
import qs
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
Rectangle {
id: root
required property var directory
property bool showBreadcrumb: true
onShowBreadcrumbChanged: {
addressInput.text = root.directory;
}
signal navigateToDirectory(string path)
property real padding: 6
implicitWidth: mainLayout.implicitWidth + padding * 2
implicitHeight: mainLayout.implicitHeight + padding * 2
color: Appearance.colors.colLayer2
function focusBreadcrumb() {
root.showBreadcrumb = false;
addressInput.forceActiveFocus();
}
RowLayout {
id: mainLayout
anchors {
fill: parent
margins: root.padding
}
spacing: 8
RippleButton {
id: parentDirButton
onClicked: root.navigateToDirectory(FileUtils.parentDirectory(root.directory))
contentItem: MaterialSymbol {
text: "drive_folder_upload"
iconSize: Appearance.font.pixelSize.larger
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
Rectangle {
id: directoryEntry
visible: !root.showBreadcrumb
anchors.fill: parent
color: Appearance.colors.colLayer1
radius: Appearance.rounding.full
implicitWidth: addressInput.implicitWidth
implicitHeight: addressInput.implicitHeight
Keys.onPressed: event => {
if (directoryEntry.visible && event.key === Qt.Key_Escape) {
root.showBreadcrumb = true;
event.accepted = true;
return;
}
event.accepted = false;
}
StyledTextInput {
id: addressInput
anchors.fill: parent
padding: 10
text: root.directory
Keys.onPressed: event => {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.navigateToDirectory(text);
root.showBreadcrumb = true;
event.accepted = true;
}
}
MouseArea {
// I-beam cursor
anchors.fill: parent
acceptedButtons: Qt.NoButton
hoverEnabled: true
cursorShape: Qt.IBeamCursor
}
}
}
Loader {
id: breadcrumbLoader
active: root.showBreadcrumb
visible: root.showBreadcrumb
anchors.fill: parent
sourceComponent: AddressBreadcrumb {
directory: root.directory
onNavigateToDirectory: dir => {
root.navigateToDirectory(dir);
}
}
}
}
RippleButton {
id: dirEditButton
toggled: !root.showBreadcrumb
onClicked: root.showBreadcrumb = !root.showBreadcrumb
contentItem: MaterialSymbol {
text: "edit"
iconSize: Appearance.font.pixelSize.larger
color: dirEditButton.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnLayer2
}
StyledToolTip {
content: Translation.tr("Edit directory")
}
}
}
}
@@ -0,0 +1,41 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
ListView {
id: root
required property var directory
property var breadcrumbDirectory: ""
Component.onCompleted: breadcrumbDirectory = directory;
onDirectoryChanged: {
if (breadcrumbDirectory.startsWith(directory)) return;
breadcrumbDirectory = directory
}
signal navigateToDirectory(string path)
orientation: ListView.Horizontal
clip: true
spacing: 2
model: breadcrumbDirectory.split("/")
delegate: SelectionGroupButton {
id: folderButton
required property var modelData
required property int index
buttonText: index === 0 ? "/" : modelData
toggled: {
if (directory.trim() === "/") return index === 0;
return index === directory.split("/").length - 1
}
leftmost: index === 0
rightmost: index === breadcrumbDirectory.split("/").length - 1
onClicked: {
root.navigateToDirectory(breadcrumbDirectory.split("/").slice(0, index + 1).join("/"))
}
}
}
@@ -0,0 +1,41 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.modules.common
import qs.modules.common.functions
// From https://github.com/caelestia-dots/shell with modifications.
// License: GPLv3
Image {
id: root
required property var fileModelData
asynchronous: true
fillMode: Image.PreserveAspectFit
source: {
if (!fileModelData.fileIsDir)
return Quickshell.iconPath("application-x-zerosize");
if ([Directories.documents, Directories.downloads, Directories.music, Directories.pictures, Directories.videos].some(dir => FileUtils.trimFileProtocol(dir) === fileModelData.filePath))
return Quickshell.iconPath(`folder-${fileModelData.fileName.toLowerCase()}`);
return Quickshell.iconPath("inode-directory");
}
onStatusChanged: {
if (status === Image.Error)
source = Quickshell.iconPath("error");
}
Process {
running: !fileModelData.fileIsDir
command: ["file", "--mime", "-b", fileModelData.filePath]
stdout: StdioCollector {
onStreamFinished: {
const mime = text.split(";")[0].replace("/", "-");
root.source = Images.validImageTypes.some(t => mime === `image-${t}`) ? fileModelData.fileUrl : Quickshell.iconPath(mime, "image-missing");
}
}
}
}
@@ -0,0 +1,26 @@
import QtQuick
import QtQuick.Controls
import qs.modules.common
import qs.modules.common.functions
ScrollBar {
id: root
policy: ScrollBar.AsNeeded
contentItem: Rectangle {
implicitWidth: 4
implicitHeight: root.visualSize
radius: width / 2
color: Appearance.colors.colOnSurfaceVariant
opacity: root.policy === ScrollBar.AlwaysOn || (root.active && root.size < 1.0) ? 0.5 : 0
Behavior on opacity {
NumberAnimation {
duration: 350
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
}
}
@@ -6,6 +6,7 @@ import QtQuick.Controls
* Does not include visual layout, but includes the easily neglected colors.
*/
TextInput {
color: Appearance.colors.colOnLayer1
renderType: Text.NativeRendering
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
selectionColor: Appearance.colors.colSecondaryContainer