wallpaper selector: nicer layout

This commit is contained in:
end-4
2025-08-23 12:25:34 +07:00
parent 0e2eea7555
commit a116ae6ab5
3 changed files with 298 additions and 188 deletions
@@ -30,7 +30,7 @@ Scope {
anchors.top: true anchors.top: true
margins { margins {
top: Appearance.sizes.barHeight + Appearance.sizes.hyprlandGapsOut top: Config?.options.bar.vertical ? Appearance.sizes.hyprlandGapsOut : Appearance.sizes.barHeight + Appearance.sizes.hyprlandGapsOut
} }
mask: Region { mask: Region {
@@ -15,10 +15,10 @@ import Quickshell.Hyprland
Item { Item {
id: root id: root
property int columns: 4 property int columns: 4
property real previewAspectRatio: 16 / 9 property real previewCellAspectRatio: 4 / 3
implicitHeight: columnLayout.implicitHeight implicitHeight: columnLayout.implicitHeight
implicitWidth: columnLayout.implicitWidth implicitWidth: columnLayout.implicitWidth
property var filteredWallpapers: Wallpapers.wallpapers property var wallpapers: Wallpapers.wallpapers
property string filterQuery: "" property string filterQuery: ""
Keys.onPressed: event => { Keys.onPressed: event => {
@@ -65,36 +65,290 @@ Item {
anchors.fill: parent anchors.fill: parent
spacing: -Appearance.sizes.elevationMargin spacing: -Appearance.sizes.elevationMargin
Item { Item { // The grid
// Search box id: wallpaperGrid
implicitHeight: filterField.implicitHeight + Appearance.sizes.elevationMargin * 2 Layout.fillWidth: true
implicitWidth: filterField.implicitWidth + Appearance.sizes.elevationMargin * 2 Layout.fillHeight: true
Layout.alignment: Qt.AlignHCenter implicitWidth: wallpaperGridBackground.implicitWidth + Appearance.sizes.elevationMargin * 2
implicitHeight: wallpaperGridBackground.implicitHeight + Appearance.sizes.elevationMargin * 2
StyledRectangularShadow { StyledRectangularShadow {
target: filterField target: wallpaperGridBackground
} }
Rectangle {
TextField { id: wallpaperGridBackground
id: filterField
anchors { anchors {
fill: parent fill: parent
margins: Appearance.sizes.elevationMargin margins: Appearance.sizes.elevationMargin
} }
implicitHeight: 44 focus: true
implicitWidth: Appearance.sizes.searchWidth
padding: 10
placeholderText: "Search wallpapers..."
placeholderTextColor: Appearance.colors.colSubtext
color: Appearance.colors.colPrimary
background: Rectangle {
color: Appearance.colors.colLayer0
border.color: Appearance.colors.colLayer0Border
border.width: 1 border.width: 1
border.color: Appearance.colors.colLayer0Border
color: Appearance.colors.colLayer0
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)
// implicitWidth: gridColumnLayout.implicitWidth
// implicitHeight: gridColumnLayout.implicitHeight
Item {
// The grid
anchors.fill: parent
GridView {
id: grid
visible: root.wallpapers.length > 0
property int currentIndex: 0
readonly property int columns: root.columns
readonly property int rows: Math.max(1, Math.ceil(count / columns))
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
}
model: ScriptModel {
values: {
let filtered = root.wallpapers.filter(w => (w.toLowerCase().includes(root.filterQuery.toLowerCase())));
// Add 'columns' empty entries to the end
for (let i = 0; i < root.columns; i++) {
filtered.push("");
}
return filtered;
}
}
onModelChanged: currentIndex = 0
function moveSelection(delta) {
for (let i = 0; i < count; i++) {
const item = itemAtIndex(i);
if (item) {
item.isHovered = false;
}
}
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 {
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 {
fill: parent
margins: 8
}
radius: Appearance.rounding.normal
color: (index === grid.currentIndex || parent.isHovered) ? Appearance.colors.colPrimary : ColorUtils.transparentize(Appearance.colors.colPrimary)
ColumnLayout {
id: wallpaperItemColumnLayout
anchors {
fill: parent
margins: 6
}
spacing: 4
Item {
id: wallpaperItemImageContainer
Layout.fillHeight: true
Layout.fillWidth: true
StyledRectangularShadow {
target: thumbnailImageLoader
radius: Appearance.rounding.small radius: Appearance.rounding.small
} }
Loader {
id: thumbnailImageLoader
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
fillMode: Image.PreserveAspectCrop
clip: true
sourceSize.width: wallpaperItemColumnLayout.width
sourceSize.height: wallpaperItemColumnLayout.height - wallpaperItemColumnLayout.spacing - wallpaperItemName.height
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 {
id: wallpaperItemName
Layout.fillWidth: true
Layout.leftMargin: 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 {
anchors.fill: parent
hoverEnabled: true
onEntered: {
for (let i = 0; i < grid.count; i++) {
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 {
id: noWallpapersFoundLabel
visible: grid.model.values.length === 0
anchors.centerIn: parent
text: "No wallpapers found"
font.family: Appearance.font.family.main font.family: Appearance.font.family.main
font.pixelSize: Appearance.font.pixelSize.normal 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 {
fill: parent
margins: extraOptionsBackground.padding
}
RippleButton {
Layout.fillHeight: true
Layout.topMargin: 2
Layout.bottomMargin: 2
buttonRadius: Appearance.rounding.full
onClicked: {
Wallpapers.openFallbackPicker();
GlobalStates.wallpaperSelectorOpen = false;
}
contentItem: RowLayout {
MaterialSymbol {
text: "files"
iconSize: Appearance.font.pixelSize.larger
}
StyledText {
text: Translation.tr("System")
}
}
StyledToolTip {
content: "Use the system file picker instead"
}
}
TextField {
id: filterField
Layout.fillHeight: true
Layout.topMargin: 2
Layout.bottomMargin: 2
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: { onTextChanged: {
root.filterQuery = text; root.filterQuery = text;
@@ -132,176 +386,28 @@ Item {
} }
} }
} }
}
Item { // The grid RippleButton {
id: wallpaperGrid
Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
implicitWidth: wallpaperGridBackground.implicitWidth + Appearance.sizes.elevationMargin * 2 Layout.topMargin: 2
implicitHeight: wallpaperGridBackground.implicitHeight + Appearance.sizes.elevationMargin * 2 Layout.bottomMargin: 2
buttonRadius: Appearance.rounding.full
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
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)
implicitWidth: grid.implicitWidth
implicitHeight: grid.implicitHeight
GridView {
id: grid
visible: root.filteredWallpapers.length > 0
property int currentIndex: 0
readonly property int columns: root.columns
readonly property int rows: Math.max(1, Math.ceil(count / columns))
anchors.fill: parent
cellWidth: width / root.columns
cellHeight: cellWidth / root.previewAspectRatio
clip: true
interactive: true
keyNavigationWraps: true
boundsBehavior: Flickable.StopAtBounds
cacheBuffer: cellHeight * 2
ScrollBar.horizontal: ScrollBar {
policy: ScrollBar.AsNeeded
}
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
}
model: ScriptModel {
values: {
return root.filteredWallpapers.filter(w => (w.toLowerCase().includes(root.filterQuery.toLowerCase())));
}
}
onModelChanged: currentIndex = 0
function moveSelection(delta) {
for (let i = 0; i < count; i++) {
const item = itemAtIndex(i);
if (item) {
item.isHovered = false;
}
}
currentIndex = Math.max(0, Math.min(count - 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 {
width: grid.cellWidth
height: grid.cellHeight
property bool isHovered: false
Image {
id: thumbnailImage
anchors {
fill: parent
margins: 8
}
source: {
const resolvedUrl = Qt.resolvedUrl(modelData);
const md5Hash = Qt.md5(resolvedUrl);
const cacheSize = "normal";
const thumbnailPath = `${Directories.genericCache}/thumbnails/${cacheSize}/${md5Hash}.png`;
return thumbnailPath;
}
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
smooth: true
mipmap: false
sourceSize.width: Math.min(128, grid.cellWidth - 16)
sourceSize.height: Math.min(96, grid.cellHeight - 16)
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: thumbnailImage.width
height: thumbnailImage.height
radius: Appearance.rounding.small
}
}
Rectangle {
anchors.fill: parent
color: "transparent"
radius: Appearance.rounding.small
border.width: (index === grid.currentIndex || parent.isHovered) ? 2 : 1
border.color: (index === grid.currentIndex || parent.isHovered) ? Appearance.colors.colSecondary : Appearance.colors.colOutlineVariant
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: {
for (let i = 0; i < grid.count; i++) {
const item = grid.itemAtIndex(i);
if (item && item !== parent) {
item.isHovered = false;
}
}
parent.isHovered = true;
grid.currentIndex = index;
}
onExited: {
parent.isHovered = false;
}
onClicked: { onClicked: {
GlobalStates.wallpaperSelectorOpen = false; GlobalStates.wallpaperSelectorOpen = false;
filterField.text = "";
Wallpapers.apply(modelData);
}
}
} }
implicitWidth: height
contentItem: MaterialSymbol {
text: "close"
iconSize: Appearance.font.pixelSize.larger
} }
Label { StyledToolTip {
id: noWallpapersFoundLabel content: "Cancel"
visible: root.filteredWallpapers.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
} }
} }
} }
@@ -33,6 +33,10 @@ Singleton {
} }
onSearchDirsChanged: reload() onSearchDirsChanged: reload()
function openFallbackPicker() {
applyProc.exec([Directories.wallpaperSwitchScriptPath])
}
function apply(path) { function apply(path) {
if (!path || path.length === 0) return if (!path || path.length === 0) return
applyProc.exec([ applyProc.exec([