wallpaper selector: add address bar

This commit is contained in:
end-4 2025-08-23 21:06:36 +07:00
parent 18ad260ce9
commit 8e6582b801
6 changed files with 444 additions and 269 deletions

View file

@ -0,0 +1,94 @@
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 // TODO: make this work
signal navigateToDirectory(string path)
property real padding: 6
implicitWidth: mainLayout.implicitWidth + padding * 2
implicitHeight: mainLayout.implicitHeight + padding * 2
color: Appearance.colors.colLayer2
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
Loader {
active: !root.showBreadcrumb
visible: !root.showBreadcrumb
anchors.fill: parent
sourceComponent: Rectangle {
color: Appearance.colors.colLayer1
radius: Appearance.rounding.full
implicitWidth: addressInput.implicitWidth
implicitHeight: addressInput.implicitHeight
StyledTextInput {
id: addressInput
anchors.fill: parent
padding: 10
text: root.directory
onAccepted: root.navigateToDirectory(text)
MouseArea {
// I-beam cursor
anchors.fill: parent
acceptedButtons: Qt.NoButton
hoverEnabled: true
cursorShape: Qt.IBeamCursor
}
}
}
}
Loader {
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
}
}
}
}

View file

@ -24,6 +24,20 @@ Singleton {
return trimmed.split(/[\\/]/).pop(); 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 * Removes the file extension from a file path or name
* @param {string} str * @param {string} str
@ -38,4 +52,18 @@ Singleton {
} }
return trimmed; 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("/");
}
} }

View file

@ -0,0 +1,38 @@
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: index === directory.split("/").length - 1
leftmost: index === 0
rightmost: index === breadcrumbDirectory.split("/").length - 1
onClicked: {
root.navigateToDirectory(breadcrumbDirectory.split("/").slice(0, index + 1).join("/"))
}
}
}

View file

@ -25,7 +25,6 @@ Scope {
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "quickshell:wallpaperSelector" WlrLayershell.namespace: "quickshell:wallpaperSelector"
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
color: "transparent" color: "transparent"
anchors.top: true anchors.top: true

View file

@ -86,324 +86,340 @@ Item {
border.color: Appearance.colors.colLayer0Border border.color: Appearance.colors.colLayer0Border
color: Appearance.colors.colLayer0 color: Appearance.colors.colLayer0
radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1 radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: wallpaperGridBackground.width
height: wallpaperGridBackground.height
radius: wallpaperGridBackground.radius
}
}
property int calculatedRows: Math.ceil(grid.count / grid.columns) property int calculatedRows: Math.ceil(grid.count / grid.columns)
// implicitWidth: gridColumnLayout.implicitWidth // implicitWidth: gridColumnLayout.implicitWidth
// implicitHeight: gridColumnLayout.implicitHeight // implicitHeight: gridColumnLayout.implicitHeight
Item { ColumnLayout {
// The grid // The grid
anchors.fill: parent anchors.fill: parent
GridView { AddressBar {
id: grid id: addressBar
visible: root.wallpapers.length > 0 Layout.margins: 4
Layout.fillWidth: true
property int currentIndex: 0 Layout.fillHeight: false
readonly property int columns: root.columns directory: Wallpapers.directory
readonly property int rows: Math.max(1, Math.ceil(count / columns)) onNavigateToDirectory: path => {
Wallpapers.directory = path;
anchors.fill: parent
cellWidth: width / root.columns
cellHeight: cellWidth / root.previewCellAspectRatio
clip: true
interactive: true
keyNavigationWraps: true
boundsBehavior: Flickable.StopAtBounds
ScrollBar.horizontal: ScrollBar {
policy: ScrollBar.AsNeeded
}
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
} }
radius: wallpaperGridBackground.radius - Layout.margins
}
model: ScriptModel { Item {
values: { id: gridDisplayRegion
let filtered = root.wallpapers.filter(w => (w.toLowerCase().includes(root.filterQuery.toLowerCase()))); Layout.fillWidth: true
// Add 'columns' empty entries to the end Layout.fillHeight: true
for (let i = 0; i < root.columns; i++) { layer.enabled: true
filtered.push(""); layer.effect: OpacityMask {
} maskSource: Rectangle {
return filtered; width: gridDisplayRegion.width
height: gridDisplayRegion.height
radius: wallpaperGridBackground.radius
} }
} }
onModelChanged: currentIndex = 0
function moveSelection(delta) { GridView {
for (let i = 0; i < count; i++) { id: grid
const item = itemAtIndex(i); visible: root.wallpapers.length > 0
if (item) {
item.isHovered = false; 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: ScrollBar {
policy: ScrollBar.AsNeeded
} }
currentIndex = Math.max(0, Math.min(root.wallpapers.length - 1, currentIndex + delta));
positionViewAtIndex(currentIndex, GridView.Contain);
}
function activateCurrent() {
const path = model[currentIndex];
if (!path)
return;
GlobalStates.wallpaperSelectorOpen = false;
filterField.text = "";
Wallpapers.apply(path);
}
delegate: Item { function moveSelection(delta) {
id: wallpaperItem for (let i = 0; i < count; i++) {
required property var modelData const item = itemAtIndex(i);
required property int index if (item) {
visible: modelData.length > 0 item.isHovered = false;
width: grid.cellWidth }
height: grid.cellHeight
property bool isHovered: false
Rectangle {
anchors {
fill: parent
margins: 8
} }
radius: Appearance.rounding.normal currentIndex = Math.max(0, Math.min(root.wallpapers.length - 1, currentIndex + delta));
color: (index === grid.currentIndex || parent.isHovered) ? Appearance.colors.colPrimary : ColorUtils.transparentize(Appearance.colors.colPrimary) positionViewAtIndex(currentIndex, GridView.Contain);
}
ColumnLayout { function activateCurrent() {
id: wallpaperItemColumnLayout const path = model[currentIndex];
if (!path)
return;
GlobalStates.wallpaperSelectorOpen = false;
filterField.text = "";
Wallpapers.apply(path);
}
model: ScriptModel {
values: root.wallpapers.filter(w => (w.toLowerCase().includes(root.filterQuery.toLowerCase())))
}
onModelChanged: currentIndex = 0
delegate: Item {
id: wallpaperItem
required property var modelData
required property int index
visible: modelData.length > 0
width: grid.cellWidth
height: grid.cellHeight
property bool isHovered: false
Rectangle {
anchors { anchors {
fill: parent fill: parent
margins: 6 margins: 8
}
radius: Appearance.rounding.normal
color: (index === grid.currentIndex || parent.isHovered) ? Appearance.colors.colPrimary : ColorUtils.transparentize(Appearance.colors.colPrimary)
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
} }
spacing: 4
Item { ColumnLayout {
id: wallpaperItemImageContainer id: wallpaperItemColumnLayout
Layout.fillHeight: true anchors {
Layout.fillWidth: true fill: parent
margins: 6
StyledRectangularShadow {
target: thumbnailImageLoader
radius: Appearance.rounding.small
} }
spacing: 4
Loader { Item {
id: thumbnailImageLoader id: wallpaperItemImageContainer
anchors.fill: parent Layout.fillHeight: true
active: wallpaperItem.visible Layout.fillWidth: true
sourceComponent: Image {
id: thumbnailImage
source: {
if (wallpaperItem.modelData.length == 0)
return;
const resolvedUrl = Qt.resolvedUrl(wallpaperItem.modelData);
const md5Hash = Qt.md5(resolvedUrl);
const cacheSize = "normal";
const thumbnailPath = `${Directories.genericCache}/thumbnails/${cacheSize}/${md5Hash}.png`;
return thumbnailPath;
}
asynchronous: true
cache: false
smooth: true
mipmap: false
fillMode: Image.PreserveAspectCrop StyledRectangularShadow {
clip: true target: thumbnailImageLoader
sourceSize.width: wallpaperItemColumnLayout.width radius: Appearance.rounding.small
sourceSize.height: wallpaperItemColumnLayout.height - wallpaperItemColumnLayout.spacing - wallpaperItemName.height }
opacity: status === Image.Ready ? 1 : 0 Loader {
Behavior on opacity { id: thumbnailImageLoader
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) anchors.fill: parent
} active: wallpaperItem.visible
sourceComponent: Image {
id: thumbnailImage
source: {
if (wallpaperItem.modelData.length == 0)
return;
const resolvedUrl = Qt.resolvedUrl(wallpaperItem.modelData);
const md5Hash = Qt.md5(resolvedUrl);
const cacheSize = "normal";
const thumbnailPath = `${Directories.genericCache}/thumbnails/${cacheSize}/${md5Hash}.png`;
return thumbnailPath;
}
asynchronous: true
cache: false
smooth: true
mipmap: false
layer.enabled: true fillMode: Image.PreserveAspectCrop
layer.effect: OpacityMask { clip: true
maskSource: Rectangle { sourceSize.width: wallpaperItemColumnLayout.width
width: wallpaperItemImageContainer.width sourceSize.height: wallpaperItemColumnLayout.height - wallpaperItemColumnLayout.spacing - wallpaperItemName.height
height: wallpaperItemImageContainer.height
radius: Appearance.rounding.small opacity: status === Image.Ready ? 1 : 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: wallpaperItemImageContainer.width
height: wallpaperItemImageContainer.height
radius: Appearance.rounding.small
}
} }
} }
} }
} }
}
StyledText { StyledText {
id: wallpaperItemName id: wallpaperItemName
Layout.fillWidth: true Layout.fillWidth: true
Layout.leftMargin: 10 Layout.leftMargin: 10
Layout.rightMargin: 10 Layout.rightMargin: 10
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
font.pixelSize: Appearance.font.pixelSize.smaller
color: (index === grid.currentIndex || parent.isHovered) ? Appearance.colors.colOnPrimary : Appearance.colors.colOnLayer0
text: FileUtils.fileNameForPath(wallpaperItem.modelData)
}
}
}
MouseArea { horizontalAlignment: Text.AlignHCenter
anchors.fill: parent elide: Text.ElideRight
hoverEnabled: true font.pixelSize: Appearance.font.pixelSize.smaller
onEntered: { color: (index === grid.currentIndex || parent.isHovered) ? Appearance.colors.colOnPrimary : Appearance.colors.colOnLayer0
for (let i = 0; i < grid.count; i++) { Behavior on color {
const item = grid.itemAtIndex(i); animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
if (item && item !== parent) { }
item.isHovered = false; text: FileUtils.fileNameForPath(wallpaperItem.modelData)
} }
} }
parent.isHovered = true;
grid.currentIndex = index;
} }
onExited: {
parent.isHovered = false; MouseArea {
} anchors.fill: parent
onClicked: { hoverEnabled: true
GlobalStates.wallpaperSelectorOpen = false; onEntered: {
filterField.text = ""; for (let i = 0; i < grid.count; i++) {
Wallpapers.apply(wallpaperItem.modelData); const item = grid.itemAtIndex(i);
if (item && item !== parent) {
item.isHovered = false;
}
}
parent.isHovered = true;
grid.currentIndex = index;
}
onExited: {
parent.isHovered = false;
}
onClicked: {
GlobalStates.wallpaperSelectorOpen = false;
filterField.text = "";
Wallpapers.apply(wallpaperItem.modelData);
}
} }
} }
} }
}
Label { Item {
id: noWallpapersFoundLabel id: extraOptions
visible: grid.model.values.length === 0
anchors.centerIn: parent
text: "No wallpapers found"
font.family: Appearance.font.family.main
font.pixelSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colSubtext
}
StyledRectangularShadow {
target: extraOptionsBackground
}
Rectangle { // Bottom toolbar
id: extraOptionsBackground
property real padding: 6
anchors {
bottom: parent.bottom
horizontalCenter: parent.horizontalCenter
bottomMargin: 8
}
color: Appearance.colors.colLayer2
implicitHeight: extraOptionsRowLayout.implicitHeight + padding * 2
implicitWidth: extraOptionsRowLayout.implicitWidth + padding * 2
radius: Appearance.rounding.full
RowLayout {
id: extraOptionsRowLayout
anchors { anchors {
fill: parent bottom: parent.bottom
margins: extraOptionsBackground.padding horizontalCenter: parent.horizontalCenter
}
implicitHeight: extraOptionsBackground.implicitHeight + extraOptionsBackground.anchors.margins * 2
implicitWidth: extraOptionsBackground.implicitWidth + extraOptionsBackground.anchors.margins * 2
StyledRectangularShadow {
target: extraOptionsBackground
} }
RippleButton { Rectangle { // Bottom toolbar
Layout.fillHeight: true id: extraOptionsBackground
Layout.topMargin: 2 property real padding: 6
Layout.bottomMargin: 2 anchors {
buttonRadius: Appearance.rounding.full fill: parent
onClicked: { margins: 8
Wallpapers.openFallbackPicker();
GlobalStates.wallpaperSelectorOpen = false;
} }
contentItem: RowLayout { color: Appearance.colors.colLayer2
MaterialSymbol { implicitHeight: extraOptionsRowLayout.implicitHeight + padding * 2
text: "files" implicitWidth: extraOptionsRowLayout.implicitWidth + padding * 2
iconSize: Appearance.font.pixelSize.larger radius: Appearance.rounding.full
}
StyledText {
text: Translation.tr("System")
}
}
StyledToolTip {
content: "Use the system file picker instead"
}
}
TextField { RowLayout {
id: filterField id: extraOptionsRowLayout
Layout.fillHeight: true anchors {
Layout.topMargin: 2 fill: parent
Layout.bottomMargin: 2 margins: extraOptionsBackground.padding
implicitWidth: 200
padding: 10
placeholderText: Translation.tr("Search wallpapers...")
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: {
root.filterQuery = text;
}
Keys.onPressed: event => {
if (text.length === 0) {
if (event.key === Qt.Key_Down || event.key === Qt.Key_Left || event.key === Qt.Key_Right) {
wallpaperGrid.forceActiveFocus();
if (event.key === Qt.Key_Down)
grid.moveSelection(grid.columns);
else if (event.key === Qt.Key_Left)
grid.moveSelection(-1);
else if (event.key === Qt.Key_Right)
grid.moveSelection(1);
event.accepted = true;
}
} else {
if (event.key === Qt.Key_Down) {
grid.moveSelection(grid.columns);
event.accepted = true;
wallpaperGrid.forceActiveFocus();
}
} }
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
grid.activateCurrent(); RippleButton {
event.accepted = true; Layout.fillHeight: true
} else if (event.key === Qt.Key_Escape) { Layout.topMargin: 2
if (filterField.text.length > 0) { Layout.bottomMargin: 2
filterField.text = ""; buttonRadius: Appearance.rounding.full
} else { onClicked: {
Wallpapers.openFallbackPicker();
GlobalStates.wallpaperSelectorOpen = false; GlobalStates.wallpaperSelectorOpen = false;
} }
event.accepted = true; contentItem: RowLayout {
MaterialSymbol {
text: "files"
iconSize: Appearance.font.pixelSize.larger
}
StyledText {
text: Translation.tr("System")
}
}
StyledToolTip {
content: "Use the system file picker instead"
}
} }
}
}
RippleButton { TextField {
Layout.fillHeight: true id: filterField
Layout.topMargin: 2 Layout.fillHeight: true
Layout.bottomMargin: 2 Layout.topMargin: 2
buttonRadius: Appearance.rounding.full Layout.bottomMargin: 2
onClicked: { implicitWidth: 200
GlobalStates.wallpaperSelectorOpen = false; padding: 10
} placeholderText: Translation.tr("Search wallpapers...")
implicitWidth: height 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
}
contentItem: MaterialSymbol { onTextChanged: {
text: "close" root.filterQuery = text;
iconSize: Appearance.font.pixelSize.larger }
}
StyledToolTip { Keys.onPressed: event => {
content: "Cancel" if (text.length === 0) {
if (event.key === Qt.Key_Down || event.key === Qt.Key_Left || event.key === Qt.Key_Right) {
wallpaperGrid.forceActiveFocus();
if (event.key === Qt.Key_Down)
grid.moveSelection(grid.columns);
else if (event.key === Qt.Key_Left)
grid.moveSelection(-1);
else if (event.key === Qt.Key_Right)
grid.moveSelection(1);
event.accepted = true;
}
} else {
if (event.key === Qt.Key_Down) {
grid.moveSelection(grid.columns);
event.accepted = true;
wallpaperGrid.forceActiveFocus();
}
}
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
grid.activateCurrent();
event.accepted = true;
} else if (event.key === Qt.Key_Escape) {
if (filterField.text.length > 0) {
filterField.text = "";
} else {
GlobalStates.wallpaperSelectorOpen = false;
}
event.accepted = true;
}
}
}
RippleButton {
Layout.fillHeight: true
Layout.topMargin: 2
Layout.bottomMargin: 2
buttonRadius: Appearance.rounding.full
onClicked: {
GlobalStates.wallpaperSelectorOpen = false;
}
implicitWidth: height
contentItem: MaterialSymbol {
text: "close"
iconSize: Appearance.font.pixelSize.larger
}
StyledToolTip {
content: "Cancel"
}
}
} }
} }
} }

View file

@ -14,7 +14,7 @@ pragma ComponentBehavior: Bound
Singleton { Singleton {
id: root id: root
property string searchDir: FileUtils.trimFileProtocol(`${Directories.pictures}/Wallpapers`) property string directory: FileUtils.trimFileProtocol(`${Directories.pictures}/Wallpapers`)
readonly property list<string> extensions: [ // TODO: add videos readonly property list<string> extensions: [ // TODO: add videos
"jpg", "jpeg", "png", "webp", "avif", "bmp", "svg" "jpg", "jpeg", "png", "webp", "avif", "bmp", "svg"
] ]
@ -42,7 +42,7 @@ Singleton {
// Folder model // Folder model
FolderListModel { FolderListModel {
id: files id: files
folder: Qt.resolvedUrl(root.searchDir) folder: Qt.resolvedUrl(root.directory)
nameFilters: root.extensions.map(ext => `*.${ext}`) nameFilters: root.extensions.map(ext => `*.${ext}`)
showDirs: false showDirs: false
showDotAndDotDot: false showDotAndDotDot: false