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
+2 -1
View File
@@ -48,7 +48,8 @@ bindld = Super+Shift,M, Toggle mute, exec, wpctl set-mute @DEFAULT_SINK@ toggle
bindl = Alt ,XF86AudioMute, exec, wpctl set-mute @DEFAULT_SOURCE@ toggle # [hidden]
bindl = ,XF86AudioMicMute, exec, wpctl set-mute @DEFAULT_SOURCE@ toggle # [hidden]
bindld = Super+Alt,M, Toggle mic, exec, wpctl set-mute @DEFAULT_SOURCE@ toggle # [hidden]
bindd = Ctrl+Super, T, Change wallpaper, exec, ~/.config/quickshell/$qsConfig/scripts/colors/switchwall.sh # Change wallpaper
bindd = Ctrl+Super, T, Toggle wallpaper selector, global, quickshell:wallpaperSelectorToggle # Wallpaper selector
bindd = Ctrl+Super, T, Change wallpaper, exec, qs -c $qsConfig ipc call TEST_ALIVE || ~/.config/quickshell/$qsConfig/scripts/colors/switchwall.sh # [hidden] Change wallpaper (fallback)
bind = Ctrl+Super, R, exec, killall ags agsv1 gjs ydotool qs quickshell; qs -c $qsConfig & # Restart widgets
##! Utilities
+1
View File
@@ -134,6 +134,7 @@ layerrule = animation slide, quickshell:verticalBar
layerrule = animation fade, quickshell:screenCorners
layerrule = animation slide right, quickshell:sidebarRight
layerrule = animation slide left, quickshell:sidebarLeft
layerrule = animation slide top, quickshell:wallpaperSelector
layerrule = animation slide bottom, quickshell:osk
layerrule = animation slide bottom, quickshell:dock
layerrule = blur, quickshell:session
+1
View File
@@ -17,6 +17,7 @@ Singleton {
property bool osdVolumeOpen: false
property bool oskOpen: false
property bool overviewOpen: false
property bool wallpaperSelectorOpen: false
property bool screenLocked: false
property bool screenLockContainsCharacters: false
property bool screenUnlockFailed: false
@@ -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
@@ -0,0 +1,124 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Io
MouseArea {
id: root
required property var fileModelData
property bool isDirectory: fileModelData.fileIsDir
property bool useThumbnail: Images.isValidImageByName(fileModelData.fileName)
property alias colBackground: background.color
property alias colText: wallpaperItemName.color
property alias radius: background.radius
property alias margins: background.anchors.margins
property alias padding: wallpaperItemColumnLayout.anchors.margins
margins: Appearance.sizes.wallpaperSelectorItemMargins
padding: Appearance.sizes.wallpaperSelectorItemPadding
signal activated()
hoverEnabled: true
onClicked: root.activated()
Rectangle {
id: background
anchors.fill: parent
radius: Appearance.rounding.normal
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
ColumnLayout {
id: wallpaperItemColumnLayout
anchors.fill: parent
spacing: 4
Item {
id: wallpaperItemImageContainer
Layout.fillHeight: true
Layout.fillWidth: true
Loader {
id: thumbnailShadowLoader
active: thumbnailImageLoader.active && thumbnailImageLoader.item.status === Image.Ready
anchors.fill: thumbnailImageLoader
sourceComponent: StyledRectangularShadow {
target: thumbnailImageLoader
anchors.fill: undefined
radius: Appearance.rounding.small
}
}
Loader {
id: thumbnailImageLoader
anchors.fill: parent
active: root.useThumbnail
sourceComponent: ThumbnailImage {
id: thumbnailImage
generateThumbnail: false
sourcePath: fileModelData.filePath
fillMode: Image.PreserveAspectCrop
clip: true
sourceSize.width: wallpaperItemColumnLayout.width
sourceSize.height: wallpaperItemColumnLayout.height - wallpaperItemColumnLayout.spacing - wallpaperItemName.height
Connections {
target: Wallpapers
function onThumbnailGenerated(directory) {
if (thumbnailImage.status !== Image.Error) return;
if (FileUtils.parentDirectory(thumbnailImage.sourcePath) !== directory) return;
thumbnailImage.source = "";
thumbnailImage.source = thumbnailImage.thumbnailPath;
}
}
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: wallpaperItemImageContainer.width
height: wallpaperItemImageContainer.height
radius: Appearance.rounding.small
}
}
}
}
Loader {
id: iconLoader
active: !root.useThumbnail
anchors.fill: parent
sourceComponent: DirectoryIcon {
fileModelData: root.fileModelData
sourceSize.width: wallpaperItemColumnLayout.width
sourceSize.height: wallpaperItemColumnLayout.height - wallpaperItemColumnLayout.spacing - wallpaperItemName.height
}
}
}
StyledText {
id: wallpaperItemName
Layout.fillWidth: true
Layout.leftMargin: 10
Layout.rightMargin: 10
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
font.pixelSize: Appearance.font.pixelSize.smaller
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
text: fileModelData.fileName
}
}
}
}
@@ -0,0 +1,75 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Hyprland
Scope {
id: root
Loader {
id: wallpaperSelectorLoader
active: GlobalStates.wallpaperSelectorOpen
sourceComponent: PanelWindow {
id: panelWindow
readonly property HyprlandMonitor monitor: Hyprland.monitorFor(panelWindow.screen)
property bool monitorIsFocused: (Hyprland.focusedMonitor?.id == monitor?.id)
exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "quickshell:wallpaperSelector"
WlrLayershell.layer: WlrLayer.Overlay
color: "transparent"
anchors.top: true
margins {
top: Config?.options.bar.vertical ? Appearance.sizes.hyprlandGapsOut : Appearance.sizes.barHeight + Appearance.sizes.hyprlandGapsOut
}
mask: Region {
item: content
}
implicitHeight: Appearance.sizes.wallpaperSelectorHeight
implicitWidth: Appearance.sizes.wallpaperSelectorWidth
HyprlandFocusGrab { // Click outside to close
id: grab
windows: [ panelWindow ]
active: wallpaperSelectorLoader.active
onCleared: () => {
if (!active) GlobalStates.wallpaperSelectorOpen = false;
}
}
WallpaperSelectorContent {
id: content
anchors {
fill: parent
}
}
}
}
IpcHandler {
target: "wallpaperSelector"
function toggle(): void {
GlobalStates.wallpaperSelectorOpen = !GlobalStates.wallpaperSelectorOpen
}
}
GlobalShortcut {
name: "wallpaperSelectorToggle"
description: "Toggle wallpaper selector"
onPressed: {
GlobalStates.wallpaperSelectorOpen = !GlobalStates.wallpaperSelectorOpen;
}
}
}
@@ -0,0 +1,400 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Io
Item {
id: root
property int columns: 4
property real previewCellAspectRatio: 4 / 3
property bool useDarkMode: Appearance.m3colors.darkmode
function updateThumbnails() {
const totalImageMargin = (Appearance.sizes.wallpaperSelectorItemMargins + Appearance.sizes.wallpaperSelectorItemPadding) * 2
const thumbnailSizeName = Images.thumbnailSizeNameForDimensions(grid.cellWidth - totalImageMargin, grid.cellHeight - totalImageMargin)
Wallpapers.generateThumbnail(thumbnailSizeName)
}
Connections {
target: Wallpapers
function onDirectoryChanged() {
root.updateThumbnails()
}
}
Keys.onPressed: event => {
if (event.key === Qt.Key_Escape) {
GlobalStates.wallpaperSelectorOpen = false;
event.accepted = true;
} else if (event.modifiers & Qt.AltModifier && event.key === Qt.Key_Up) {
Wallpapers.setDirectory(FileUtils.parentDirectory(Wallpapers.directory));
event.accepted = true;
} else if (event.key === Qt.Key_Left) {
grid.moveSelection(-1);
event.accepted = true;
} else if (event.key === Qt.Key_Right) {
grid.moveSelection(1);
event.accepted = true;
} else if (event.key === Qt.Key_Up) {
grid.moveSelection(-grid.columns);
event.accepted = true;
} else if (event.key === Qt.Key_Down) {
grid.moveSelection(grid.columns);
event.accepted = true;
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
grid.activateCurrent();
event.accepted = true;
} else if (event.key === Qt.Key_Backspace) {
if (filterField.text.length > 0) {
filterField.text = filterField.text.substring(0, filterField.text.length - 1);
}
filterField.forceActiveFocus();
event.accepted = true;
} else if (event.modifiers & Qt.ControlModifier && event.key === Qt.Key_L) {
addressBar.focusBreadcrumb();
event.accepted = true;
} else if (event.key === Qt.Key_Slash) {
filterField.forceActiveFocus();
event.accepted = true;
} else {
if (event.text.length > 0) {
filterField.text += event.text;
filterField.cursorPosition = filterField.text.length;
filterField.forceActiveFocus();
}
event.accepted = true;
}
}
implicitHeight: mainLayout.implicitHeight
implicitWidth: mainLayout.implicitWidth
StyledRectangularShadow {
target: wallpaperGridBackground
}
Rectangle {
id: wallpaperGridBackground
anchors {
fill: parent
margins: Appearance.sizes.elevationMargin
}
focus: true
border.width: 1
border.color: Appearance.colors.colLayer0Border
color: Appearance.colors.colLayer0
radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1
property int calculatedRows: Math.ceil(grid.count / grid.columns)
implicitWidth: gridColumnLayout.implicitWidth
implicitHeight: gridColumnLayout.implicitHeight
RowLayout {
id: mainLayout
anchors.fill: parent
spacing: -4
Rectangle {
Layout.fillHeight: true
Layout.margins: 4
implicitWidth: quickDirColumnLayout.implicitWidth
implicitHeight: quickDirColumnLayout.implicitHeight
color: Appearance.colors.colLayer1
radius: wallpaperGridBackground.radius - Layout.margins
ColumnLayout {
id: quickDirColumnLayout
anchors.fill: parent
spacing: 0
StyledText {
Layout.margins: 12
font {
pixelSize: Appearance.font.pixelSize.normal
weight: Font.Medium
}
text: Translation.tr("Pick a wallpaper")
}
ListView {
// Quick dirs
Layout.fillHeight: true
Layout.margins: 4
implicitWidth: 140
clip: true
model: [
{ icon: "home", name: "Home", path: Directories.home },
{ icon: "docs", name: "Documents", path: Directories.documents },
{ icon: "download", name: "Downloads", path: Directories.downloads },
{ icon: "image", name: "Pictures", path: Directories.pictures },
{ icon: "movie", name: "Videos", path: Directories.videos },
{ icon: "", name: "---", path: "INTENTIONALLY_INVALID_DIR" },
{ icon: "wallpaper", name: "Wallpapers", path: `${Directories.pictures}/Wallpapers` },
{ icon: "favorite", name: "Homework", path: `${Directories.pictures}/homework` },
]
delegate: RippleButton {
id: quickDirButton
required property var modelData
anchors {
left: parent.left
right: parent.right
}
onClicked: Wallpapers.setDirectory(quickDirButton.modelData.path)
enabled: modelData.icon.length > 0
toggled: Wallpapers.directory === FileUtils.trimFileProtocol(modelData.path)
colBackgroundToggled: Appearance.colors.colSecondaryContainer
colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover
colRippleToggled: Appearance.colors.colSecondaryContainerActive
contentItem: RowLayout {
MaterialSymbol {
color: quickDirButton.toggled ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer1
iconSize: Appearance.font.pixelSize.larger
text: quickDirButton.modelData.icon
}
StyledText {
Layout.fillWidth: true
horizontalAlignment: Text.AlignLeft
color: quickDirButton.toggled ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer1
text: quickDirButton.modelData.name
}
}
}
}
}
}
ColumnLayout {
id: gridColumnLayout
Layout.fillWidth: true
Layout.fillHeight: true
AddressBar {
id: addressBar
Layout.margins: 4
Layout.fillWidth: true
Layout.fillHeight: false
directory: Wallpapers.directory
onNavigateToDirectory: path => {
Wallpapers.setDirectory(path.length == 0 ? "/" : path);
}
radius: wallpaperGridBackground.radius - Layout.margins
}
Item {
id: gridDisplayRegion
Layout.fillWidth: true
Layout.fillHeight: true
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: gridDisplayRegion.width
height: gridDisplayRegion.height
radius: wallpaperGridBackground.radius
}
}
GridView {
id: grid
visible: Wallpapers.folderModel.count > 0
readonly property int columns: root.columns
readonly property int rows: Math.max(1, Math.ceil(count / columns))
property int currentIndex: 0
anchors.fill: parent
cellWidth: width / root.columns
cellHeight: cellWidth / root.previewCellAspectRatio
interactive: true
clip: true
keyNavigationWraps: true
boundsBehavior: Flickable.StopAtBounds
bottomMargin: extraOptions.implicitHeight
ScrollBar.vertical: StyledScrollBar {}
Component.onCompleted: {
root.updateThumbnails()
}
function moveSelection(delta) {
currentIndex = Math.max(0, Math.min(grid.model.count - 1, currentIndex + delta));
positionViewAtIndex(currentIndex, GridView.Contain);
}
function activateCurrent() {
const filePath = grid.model.get(currentIndex, "filePath")
Wallpapers.select(filePath, root.useDarkMode);
filterField.text = "";
}
model: Wallpapers.folderModel
onModelChanged: currentIndex = 0
delegate: WallpaperDirectoryItem {
required property var modelData
required property int index
fileModelData: modelData
width: grid.cellWidth
height: grid.cellHeight
colBackground: (index === grid?.currentIndex || containsMouse) ? Appearance.colors.colPrimary : (fileModelData.filePath === Config.options.background.wallpaperPath) ? Appearance.colors.colSecondaryContainer : ColorUtils.transparentize(Appearance.colors.colPrimaryContainer)
colText: (index === grid.currentIndex || containsMouse) ? Appearance.colors.colOnPrimary : (fileModelData.filePath === Config.options.background.wallpaperPath) ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer0
onEntered: {
grid.currentIndex = index;
}
onActivated: {
Wallpapers.select(fileModelData.filePath, root.useDarkMode);
filterField.text = "";
}
}
}
Item {
id: extraOptions
anchors {
bottom: parent.bottom
horizontalCenter: parent.horizontalCenter
}
implicitHeight: extraOptionsBackground.implicitHeight + extraOptionsBackground.anchors.margins * 2
implicitWidth: extraOptionsBackground.implicitWidth + extraOptionsBackground.anchors.margins * 2
StyledRectangularShadow {
target: extraOptionsBackground
}
Rectangle { // Bottom toolbar
id: extraOptionsBackground
property real padding: 6
anchors {
fill: parent
margins: 8
}
color: Appearance.colors.colLayer2
implicitHeight: extraOptionsRowLayout.implicitHeight + padding * 2
implicitWidth: extraOptionsRowLayout.implicitWidth + padding * 2
radius: Appearance.rounding.full
RowLayout {
id: extraOptionsRowLayout
anchors {
fill: parent
margins: extraOptionsBackground.padding
}
RippleButton {
Layout.fillHeight: true
Layout.topMargin: 2
Layout.bottomMargin: 2
implicitWidth: height
buttonRadius: Appearance.rounding.full
onClicked: {
Wallpapers.openFallbackPicker(root.useDarkMode);
GlobalStates.wallpaperSelectorOpen = false;
}
contentItem: MaterialSymbol {
text: "open_in_new"
iconSize: Appearance.font.pixelSize.larger
}
StyledToolTip {
content: Translation.tr("Use the system file picker instead")
}
}
RippleButton {
Layout.fillHeight: true
Layout.topMargin: 2
Layout.bottomMargin: 2
implicitWidth: height
buttonRadius: Appearance.rounding.full
onClicked: root.useDarkMode = !root.useDarkMode
contentItem: MaterialSymbol {
text: root.useDarkMode ? "dark_mode" : "light_mode"
iconSize: Appearance.font.pixelSize.larger
}
StyledToolTip {
content: Translation.tr("Click to toggle light/dark mode (applied when wallpaper is chosen)")
}
}
TextField {
id: filterField
Layout.fillHeight: true
Layout.topMargin: 2
Layout.bottomMargin: 2
implicitWidth: 200
padding: 10
placeholderText: focus ? Translation.tr("Search wallpapers") : Translation.tr("Hit \"/\" to search")
placeholderTextColor: Appearance.colors.colSubtext
color: Appearance.colors.colOnLayer0
font.pixelSize: Appearance.font.pixelSize.small
renderType: Text.NativeRendering
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
selectionColor: Appearance.colors.colSecondaryContainer
background: Rectangle {
color: Appearance.colors.colLayer1
radius: Appearance.rounding.full
}
onTextChanged: {
Wallpapers.searchQuery = text;
}
Keys.onPressed: event => {
if (text.length !== 0) {
// No filtering, just navigate grid
if (event.key === Qt.Key_Down) {
grid.moveSelection(grid.columns);
event.accepted = true;
}
if (event.key === Qt.Key_Up) {
grid.moveSelection(-grid.columns);
event.accepted = true;
}
}
event.accepted = false;
}
}
RippleButton {
Layout.fillHeight: true
Layout.topMargin: 2
Layout.bottomMargin: 2
buttonRadius: Appearance.rounding.full
onClicked: {
GlobalStates.wallpaperSelectorOpen = false;
}
contentItem: StyledText {
text: "Cancel"
}
}
}
}
}
}
}
}
}
Connections {
target: GlobalStates
function onWallpaperSelectorOpenChanged() {
if (GlobalStates.wallpaperSelectorOpen && monitorIsFocused) {
filterField.forceActiveFocus();
}
}
}
Connections {
target: Wallpapers
function onChanged() {
GlobalStates.wallpaperSelectorOpen = false;
}
}
}
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env -S\_/bin/sh\_-xc\_"source\_\$(eval\_echo\_\$ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate&&exec\_python\_-E\_"\$0"\_"\$@""
# From https://github.com/difference-engine/thumbnail-generator-ubuntu (MIT License)
# Since the script is small and the maintainers seem inactive to accept my PR (#11) I decided to just copy it over.
# When it gets merged and the python package gets updated we can just use it
import os
import sys
from multiprocessing import Pool
from pathlib import Path
from typing import List, Union
import click
import gi
from loguru import logger
from tqdm import tqdm
gi.require_version("GnomeDesktop", "3.0")
from gi.repository import Gio, GnomeDesktop # isort:skip
thumbnail_size_map = {
"normal": GnomeDesktop.DesktopThumbnailSize.NORMAL,
"large": GnomeDesktop.DesktopThumbnailSize.LARGE,
"x-large": GnomeDesktop.DesktopThumbnailSize.XLARGE,
"xx-large": GnomeDesktop.DesktopThumbnailSize.XXLARGE,
}
factory = None
logger.remove()
logger.add(sys.stdout, level="INFO")
logger.add("/tmp/thumbgen.log", level="DEBUG", rotation="100 MB")
def make_thumbnail(fpath: str) -> bool:
mtime = os.path.getmtime(fpath)
# Use Gio to determine the URI and mime type
f = Gio.file_new_for_path(str(fpath))
uri = f.get_uri()
info = f.query_info("standard::content-type", Gio.FileQueryInfoFlags.NONE, None)
mime_type = info.get_content_type()
if factory.lookup(uri, mtime) is not None:
logger.debug("FRESH {}".format(uri))
return False
if not factory.can_thumbnail(uri, mime_type, mtime):
logger.debug("UNSUPPORTED {}".format(uri))
return False
thumbnail = factory.generate_thumbnail(uri, mime_type)
if thumbnail is None:
logger.debug("ERROR {}".format(uri))
return False
logger.debug("OK {}".format(uri))
factory.save_thumbnail(thumbnail, uri, mtime)
return True
@logger.catch()
def thumbnail_folder(*, dir_path: Path, workers: int, only_images: bool, recursive: bool) -> None:
all_files = get_all_files(dir_path=dir_path, recursive=recursive)
if only_images:
all_files = get_all_images(all_files=all_files)
all_files = [str(fpath) for fpath in all_files]
with Pool(processes=workers) as p:
list(tqdm(p.imap(make_thumbnail, all_files), total=len(all_files)))
def get_all_images(*, all_files: List[Path]) -> List[Path]:
img_suffixes = [".jpg", ".jpeg", ".png", ".gif"]
all_images = [fpath for fpath in all_files if fpath.suffix in img_suffixes]
print("Found {} images".format(len(all_images)))
return all_images
def get_all_files(*, dir_path: Path, recursive: bool) -> List[Path]:
if not (dir_path.exists() and dir_path.is_dir()):
raise ValueError("{} doesn't exist or isn't a valid directory!".format(dir_path.resolve()))
if recursive:
all_files = dir_path.rglob("*")
else:
all_files = dir_path.glob("*")
all_files = [fpath for fpath in all_files if fpath.is_file()]
print("Found {} files in the directory: {}".format(len(all_files), dir_path.resolve()))
return all_files
@click.command()
@click.option(
"-d", "--img_dirs", required=True, help='directories to generate thumbnails seperated by space, eg: "dir1/dir2 dir3"'
)
@click.option(
"-s", "--size", default="normal", type=click.Choice(["normal", "large", "x-large", "xx-large"]), help="Thumbnail size: normal, large, x-large, xx-large"
)
@click.option("-w", "--workers", default=1, help="no of cpus to use for processing")
@click.option(
"-i", "--only_images", is_flag=True, default=False, help="Whether to only look for images to be thumbnailed"
)
@click.option("-r", "--recursive", is_flag=True, default=False, help="Whether to recursively look for files")
def main(img_dirs: str, size: str, workers: str, only_images: bool, recursive: bool) -> None:
img_dirs = [Path(img_dir) for img_dir in img_dirs.split()]
global factory
factory = GnomeDesktop.DesktopThumbnailFactory.new(thumbnail_size_map[size])
for img_dir in img_dirs:
thumbnail_folder(dir_path=img_dir, workers=workers, only_images=only_images, recursive=recursive)
print("Thumbnail Generation Completed!")
if __name__ == "__main__":
main()
@@ -0,0 +1,130 @@
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Qt.labs.folderlistmodel
import Quickshell
import Quickshell.Io
pragma Singleton
pragma ComponentBehavior: Bound
/**
* Provides a list of wallpapers and an "apply" action that calls the existing
* switchwall.sh script. Pretty much a limited file browsing service.
*/
Singleton {
id: root
property string thumbgenScriptPath: `${FileUtils.trimFileProtocol(Directories.scriptPath)}/thumbnails/thumbgen.py`
property string directory: FileUtils.trimFileProtocol(`${Directories.pictures}/Wallpapers`)
property alias folderModel: folderModel // Expose for direct binding when needed
property string searchQuery: ""
readonly property list<string> extensions: [ // TODO: add videos
"jpg", "jpeg", "png", "webp", "avif", "bmp", "svg"
]
property list<string> wallpapers: [] // List of absolute file paths (without file://)
signal changed()
signal thumbnailGenerated(directory: string)
// Executions
Process {
id: applyProc
}
function openFallbackPicker(darkMode = Appearance.m3colors.darkmode) {
applyProc.exec([
Directories.wallpaperSwitchScriptPath,
"--mode", (darkMode ? "dark" : "light")
])
}
function apply(path, darkMode = Appearance.m3colors.darkmode) {
if (!path || path.length === 0) return
applyProc.exec([
Directories.wallpaperSwitchScriptPath,
"--image", path,
"--mode", (darkMode ? "dark" : "light")
])
root.changed()
}
Process {
id: selectProc
property string filePath: ""
property bool darkMode: Appearance.m3colors.darkmode
function select(filePath, darkMode = Appearance.m3colors.darkmode) {
selectProc.filePath = filePath
selectProc.darkMode = darkMode
selectProc.exec(["test", "-d", FileUtils.trimFileProtocol(filePath)])
}
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
setDirectory(selectProc.filePath);
return;
}
root.apply(selectProc.filePath, selectProc.darkMode);
}
}
function select(filePath, darkMode = Appearance.m3colors.darkmode) {
selectProc.select(filePath, darkMode);
}
Process {
id: validateDirProc
property string nicePath: ""
function setDirectoryIfValid(path) {
validateDirProc.nicePath = FileUtils.trimFileProtocol(path).replace(/\/+$/, "")
if (/^\/*$/.test(validateDirProc.nicePath)) validateDirProc.nicePath = "/";
validateDirProc.exec(["test", "-d", nicePath])
}
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
root.directory = validateDirProc.nicePath
}
}
}
function setDirectory(path) {
validateDirProc.setDirectoryIfValid(path)
}
// Folder model
FolderListModel {
id: folderModel
folder: Qt.resolvedUrl(root.directory)
caseSensitive: false
nameFilters: root.extensions.map(ext => `*${searchQuery.split(" ").filter(s => s.length > 0).map(s => `*${s}*`)}*.${ext}`)
showDirs: true
showDotAndDotDot: false
showOnlyReadable: true
sortField: FolderListModel.Time
sortReversed: false
onCountChanged: {
root.wallpapers = []
for (let i = 0; i < folderModel.count; i++) {
const path = folderModel.get(i, "filePath") || FileUtils.trimFileProtocol(folderModel.get(i, "fileURL"))
if (path && path.length) root.wallpapers.push(path)
}
}
}
// Thumbnail generation
function generateThumbnail(size: string) {
if (!["normal", "large", "x-large", "xx-large"].includes(size)) throw new Error("Invalid thumbnail size");
thumbgenProc.directory = root.directory
thumbgenProc.running = false
thumbgenProc.command = [
thumbgenScriptPath,
"--size", size,
"-d", `${root.directory}`
]
thumbgenProc.running = true
}
Process {
id: thumbgenProc
property string directory
onExited: (exitCode, exitStatus) => {
root.thumbnailGenerated(thumbgenProc.directory)
}
}
}
+3
View File
@@ -23,6 +23,7 @@ import "./modules/sessionScreen/"
import "./modules/sidebarLeft/"
import "./modules/sidebarRight/"
import "./modules/verticalBar/"
import "./modules/wallpaperSelector/"
import QtQuick
import QtQuick.Window
@@ -49,6 +50,7 @@ ShellRoot {
property bool enableSidebarLeft: true
property bool enableSidebarRight: true
property bool enableVerticalBar: true
property bool enableWallpaperSelector: true
// Force initialization of some singletons
Component.onCompleted: {
@@ -76,5 +78,6 @@ ShellRoot {
LazyLoader { active: enableSidebarLeft; component: SidebarLeft {} }
LazyLoader { active: enableSidebarRight; component: SidebarRight {} }
LazyLoader { active: enableVerticalBar && Config.ready && Config.options.bar.vertical; component: VerticalBar {} }
LazyLoader { active: enableWallpaperSelector; component: WallpaperSelector {} }
}
+5
View File
@@ -8,3 +8,8 @@ materialyoucolor
libsass
material-color-utilities
setproctitle
click
loguru
pycairo
pygobject
tqdm
+12
View File
@@ -4,8 +4,12 @@ build==1.2.2.post1
# via -r scriptdata/requirements.in
cffi==1.17.1
# via pywayland
click==8.2.1
# via -r scriptdata/requirements.in
libsass==0.23.0
# via -r scriptdata/requirements.in
loguru==0.7.3
# via -r scriptdata/requirements.in
material-color-utilities==0.2.1
# via -r scriptdata/requirements.in
materialyoucolor==2.0.10
@@ -22,8 +26,14 @@ pillow==11.1.0
# material-color-utilities
psutil==6.1.1
# via -r scriptdata/requirements.in
pycairo==1.28.0
# via
# -r scriptdata/requirements.in
# pygobject
pycparser==2.22
# via cffi
pygobject==3.52.3
# via -r scriptdata/requirements.in
pyproject-hooks==1.2.0
# via build
pywayland==0.4.18
@@ -34,5 +44,7 @@ setuptools==80.9.0
# via setuptools-scm
setuptools-scm==8.1.0
# via -r scriptdata/requirements.in
tqdm==4.67.1
# via -r scriptdata/requirements.in
wheel==0.45.1
# via -r scriptdata/requirements.in