feat: launcher wallpaper selector

This commit is contained in:
2 * r + 2 * t 2025-05-03 23:56:37 +10:00
parent d01194d1fc
commit 00d3c1a472
13 changed files with 429 additions and 105 deletions

View file

@ -5,11 +5,14 @@ import QtQuick
Singleton {
readonly property int maxShown: 8
readonly property int maxWallpapers: 9 // Warning: even numbers look bad
readonly property string actionPrefix: ">"
readonly property Sizes sizes: Sizes {}
component Sizes: QtObject {
readonly property int width: 600
readonly property int itemWidth: 600
readonly property int itemHeight: 57
readonly property int wallpaperWidth: 280
readonly property int wallpaperHeight: 200
}
}

View file

@ -9,10 +9,12 @@ PaddedRect {
required property Actions.Action modelData
required property var list
implicitWidth: ListView.view.width
implicitHeight: LauncherConfig.sizes.itemHeight
padding: [Appearance.padding.smaller, Appearance.padding.larger]
anchors.left: parent.left
anchors.right: parent.right
StateLayer {
radius: Appearance.rounding.normal

View file

@ -10,10 +10,12 @@ PaddedRect {
required property DesktopEntry modelData
required property Scope launcher
implicitWidth: ListView.view.width
implicitHeight: LauncherConfig.sizes.itemHeight
padding: [Appearance.padding.smaller, Appearance.padding.normal]
anchors.left: parent.left
anchors.right: parent.right
StateLayer {
radius: Appearance.rounding.normal

View file

@ -30,17 +30,12 @@ ListView {
onValuesChanged: root.currentIndex = 0
}
clip: true
spacing: Appearance.spacing.small
orientation: Qt.Vertical
implicitHeight: (LauncherConfig.sizes.itemHeight + spacing) * Math.min(LauncherConfig.maxShown, count) - spacing
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: root.padding
highlightMoveDuration: Appearance.anim.durations.normal
highlightResizeDuration: 0
highlight: StyledRect {
radius: Appearance.rounding.normal
@ -111,13 +106,6 @@ ListView {
}
}
Behavior on implicitHeight {
Anim {
duration: Appearance.anim.durations.large
easing.bezierCurve: Appearance.anim.curves.emphasizedDecel
}
}
Behavior on isAction {
SequentialAnimation {
ParallelAnimation {

View file

@ -5,13 +5,12 @@ import QtQuick.Shapes
Shape {
id: root
required property real wrapperWidth
required property real realWrapperHeight
readonly property int rounding: Appearance.rounding.large
readonly property int roundingY: Math.min(rounding, realWrapperHeight / 2)
readonly property real wrapperHeight: realWrapperHeight - 1 // Pixel issues :sob:
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
preferredRendererType: Shape.CurveRenderer
@ -41,7 +40,7 @@ Shape {
radiusY: root.roundingY
}
PathLine {
x: wrapper.width - root.rounding * 2
x: root.wrapperWidth - root.rounding * 2
}
PathArc {
relativeX: root.rounding

View file

@ -1,3 +1,5 @@
pragma ComponentBehavior: Bound
import "root:/widgets"
import "root:/services"
import "root:/config"
@ -13,7 +15,7 @@ Item {
readonly property int spacing: Appearance.spacing.normal
readonly property int rounding: Appearance.rounding.large
implicitWidth: LauncherConfig.sizes.width
implicitWidth: listWrapper.width + padding * 2
implicitHeight: search.height + listWrapper.height + padding * 2 + spacing
anchors.bottom: parent.bottom
@ -24,26 +26,23 @@ Item {
color: Appearance.alpha(Appearance.colours.m3surfaceContainerHigh, true)
radius: root.rounding
implicitHeight: Math.max(empty.height, list.height) + root.padding * 2
anchors.left: parent.left
anchors.right: parent.right
implicitWidth: list.width + root.padding * 2
implicitHeight: list.height + root.padding * 2
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: search.top
anchors.bottomMargin: root.spacing
anchors.margins: root.padding
AppList {
ContentList {
id: list
padding: root.padding
search: search
launcher: root.launcher
}
EmptyIndicator {
id: empty
empty: list.count === 0
search: search
padding: root.padding
spacing: root.spacing
rounding: root.rounding
}
}
@ -68,18 +67,22 @@ Item {
}
onAccepted: {
if (list.currentItem) {
if (list.isAction)
list.currentItem.modelData.onClicked(list);
else {
Apps.launch(list.currentItem?.modelData);
const currentItem = list.currentList?.currentItem;
if (currentItem) {
if (list.showWallpapers) {
// TODO
console.log(currentItem.modelData.path);
} else if (text.startsWith(LauncherConfig.actionPrefix)) {
currentItem.modelData.onClicked(list.currentList);
} else {
Apps.launch(currentItem.modelData);
root.launcher.launcherVisible = false;
}
}
}
Keys.onUpPressed: list.decrementCurrentIndex()
Keys.onDownPressed: list.incrementCurrentIndex()
Keys.onUpPressed: list.currentList?.decrementCurrentIndex()
Keys.onDownPressed: list.currentList?.incrementCurrentIndex()
Keys.onEscapePressed: root.launcher.launcherVisible = false
@ -91,7 +94,9 @@ Item {
search.forceActiveFocus();
else {
search.text = "";
list.currentIndex = 0;
const current = list.currentList;
if (current)
current.currentIndex = 0;
}
}
}

View file

@ -0,0 +1,193 @@
pragma ComponentBehavior: Bound
import "root:/widgets"
import "root:/services"
import "root:/config"
import Quickshell
import QtQuick
import QtQuick.Controls
Item {
id: root
required property Scope launcher
required property TextField search
required property int padding
required property int spacing
required property int rounding
property bool showWallpapers: search.text.startsWith(`${LauncherConfig.actionPrefix}wallpaper `)
property var currentList: (showWallpapers ? wallpaperList : appList).item
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: root.padding
clip: true
state: showWallpapers ? "wallpapers" : "apps"
states: [
State {
name: "apps"
PropertyChanges {
root.implicitWidth: LauncherConfig.sizes.itemWidth
root.implicitHeight: Math.max(empty.height, appList.height)
appList.active: true
}
AnchorChanges {
anchors.left: root.parent.left
anchors.right: root.parent.right
}
},
State {
name: "wallpapers"
PropertyChanges {
root.implicitWidth: Math.max(LauncherConfig.sizes.itemWidth, wallpaperList.width)
root.implicitHeight: LauncherConfig.sizes.wallpaperHeight
wallpaperList.active: true
}
}
]
transitions: Transition {
from: "*"
to: "*"
SequentialAnimation {
NumberAnimation {
target: root
property: "opacity"
from: 1
to: 0
duration: Appearance.anim.durations.small
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.standard
}
PropertyAction {
targets: [appList, wallpaperList]
properties: "active"
}
ParallelAnimation {
NumberAnimation {
target: root
properties: "implicitWidth,implicitHeight"
duration: Appearance.anim.durations.large
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.emphasized
}
NumberAnimation {
target: root
property: "opacity"
from: 0
to: 1
duration: Appearance.anim.durations.large
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.standard
}
}
}
}
Loader {
id: appList
active: false
asynchronous: true
anchors.left: parent.left
anchors.right: parent.right
sourceComponent: AppList {
padding: root.padding
search: root.search
launcher: root.launcher
}
}
Loader {
id: wallpaperList
active: false
asynchronous: true
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
sourceComponent: WallpaperList {
search: root.search
launcher: root.launcher
}
}
Item {
id: empty
opacity: root.currentList.count === 0 ? 1 : 0
scale: root.currentList.count === 0 ? 1 : 0.5
implicitWidth: icon.width + text.width + Appearance.spacing.small
implicitHeight: icon.height
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
MaterialIcon {
id: icon
text: "manage_search"
color: Appearance.colours.m3outline
font.pointSize: Appearance.font.size.extraLarge
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
id: text
anchors.left: icon.right
anchors.leftMargin: Appearance.spacing.small
anchors.verticalCenter: parent.verticalCenter
text: qsTr("No results")
color: Appearance.colours.m3outline
font.pointSize: Appearance.font.size.larger
font.weight: 500
}
Behavior on opacity {
NumberAnimation {
duration: Appearance.anim.durations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.standard
}
}
Behavior on scale {
NumberAnimation {
duration: Appearance.anim.durations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.standard
}
}
}
Behavior on implicitWidth {
NumberAnimation {
duration: Appearance.anim.durations.large
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.emphasizedDecel
}
}
Behavior on implicitHeight {
NumberAnimation {
duration: Appearance.anim.durations.large
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.emphasizedDecel
}
}
}

View file

@ -1,62 +0,0 @@
import "root:/widgets"
import "root:/config"
import QtQuick
Loader {
id: root
required property bool empty
active: false
opacity: 0
scale: 0
asynchronous: true
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
sourceComponent: Item {
implicitWidth: childrenRect.width
implicitHeight: icon.height
MaterialIcon {
id: icon
text: "manage_search"
color: Appearance.colours.m3outline
font.pointSize: Appearance.font.size.extraLarge
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
anchors.left: icon.right
anchors.leftMargin: Appearance.spacing.small
anchors.verticalCenter: icon.verticalCenter
text: qsTr("No matching apps found")
color: Appearance.colours.m3outline
font.pointSize: Appearance.font.size.larger
font.weight: 500
}
}
states: State {
name: "visible"
when: root.empty
PropertyChanges {
root.active: true
root.opacity: 1
root.scale: 1
}
}
transitions: Transition {
NumberAnimation {
properties: "opacity,scale"
duration: Appearance.anim.durations.large
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.standard
}
}
}

View file

@ -21,23 +21,31 @@ Scope {
keyboardFocus: root.launcherVisible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
visible: wrapper.shouldBeVisible
width: content.width + bg.rounding * 2
mask: Region {
item: wrapper
}
anchors.top: true
anchors.left: true
anchors.bottom: true
anchors.right: true
Background {
id: bg
anchors.horizontalCenter: parent.horizontalCenter
wrapperWidth: wrapper.width
realWrapperHeight: Math.min(wrapper.height, content.height)
}
Wrapper {
id: wrapper
anchors.horizontalCenter: parent.horizontalCenter
implicitWidth: content.width + bg.rounding * 2
launcherVisible: root.launcherVisible
contentHeight: content.height

View file

@ -0,0 +1,91 @@
import "root:/widgets"
import "root:/services"
import "root:/config"
import Quickshell
import QtQuick
import QtQuick.Effects
StyledRect {
id: root
required property Wallpapers.Wallpaper modelData
scale: PathView.isCurrentItem ? 1 : PathView.onPath ? 0.8 : 0
opacity: PathView.onPath ? 1 : 0
z: PathView.isCurrentItem ? 1 : 0
implicitWidth: image.width + Appearance.padding.larger * 2
implicitHeight: image.height + label.height + Appearance.spacing.small / 2 + Appearance.padding.normal * 2
StateLayer {
radius: Appearance.rounding.normal
function onClicked(): void {
console.log("clicked");
}
}
Image {
id: image
anchors.horizontalCenter: parent.horizontalCenter
y: Appearance.padding.normal
visible: false
source: `file://${root.modelData.path}`
asynchronous: true
fillMode: Image.PreserveAspectCrop
smooth: !root.PathView.view.moving
width: LauncherConfig.sizes.wallpaperWidth
height: width / 16 * 9
sourceSize.width: width
sourceSize.height: height
}
Rectangle {
id: mask
layer.enabled: true
visible: false
anchors.fill: image
width: image.width
height: image.height
radius: Appearance.rounding.normal
}
MultiEffect {
anchors.fill: image
source: image
maskEnabled: true
maskSource: mask
}
StyledText {
id: label
anchors.top: image.bottom
anchors.topMargin: Appearance.spacing.small / 2
anchors.horizontalCenter: parent.horizontalCenter
renderType: Text.QtRendering
text: root.modelData.name
font.pointSize: Appearance.font.size.small
}
Behavior on scale {
NumberAnimation {
duration: Appearance.anim.durations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.standard
}
}
Behavior on opacity {
NumberAnimation {
duration: Appearance.anim.durations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.standard
}
}
}

View file

@ -0,0 +1,44 @@
import "root:/widgets"
import "root:/services"
import "root:/config"
import Quickshell
import QtQuick
import QtQuick.Controls
PathView {
id: root
required property TextField search
required property Scope launcher
model: ScriptModel {
values: {
const list = Wallpapers.fuzzyQuery(root.search.text.split(" ").slice(1).join(" "));
if (list.length > 1 && list.length % 2 === 0)
list.length -= 1; // Always show odd number
return list;
}
onValuesChanged: root.currentIndex = 0
}
implicitWidth: Math.min(LauncherConfig.maxWallpapers, count) * (LauncherConfig.sizes.wallpaperWidth * 0.8 + Appearance.padding.larger * 2)
pathItemCount: LauncherConfig.maxWallpapers
cacheItemCount: 4
snapMode: PathView.SnapToItem
preferredHighlightBegin: 0.5
preferredHighlightEnd: 0.5
highlightRangeMode: PathView.StrictlyEnforceRange
highlightMoveDuration: Appearance.anim.durations.short
delegate: WallpaperItem {}
path: Path {
startY: root.height / 2
PathLine {
x: root.width
relativeY: 0
}
}
}

View file

@ -8,8 +8,6 @@ Item {
required property real contentHeight
property bool shouldBeVisible
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 0

53
services/Wallpapers.qml Normal file
View file

@ -0,0 +1,53 @@
pragma Singleton
import "root:/utils/scripts/fuzzysort.js" as Fuzzy
import Quickshell
import Quickshell.Io
import QtQuick
import Qt.labs.platform
Singleton {
id: root
readonly property string path: `${StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0]}/Wallpapers`
property list<Wallpaper> list
readonly property list<var> preppedPaths: list.map(w => ({
path: Fuzzy.prepare(w.path),
wall: w
}))
function fuzzyQuery(search: string): var {
return Fuzzy.go(search, preppedPaths, {
all: true,
keys: ["name", "path"],
scoreFn: r => r[0].score * 0.9 + r[1].score * 0.1
}).map(r => r.obj.wall);
}
Process {
running: true
command: ["fd", ".", root.path.slice(7), "-t", "f", "-e", "jpg", "-e", "jpeg", "-e", "png", "-e", "svg"]
stdout: SplitParser {
splitMarker: ""
onRead: data => {
const list = data.trim().split("\n");
root.list = list.map(p => wallpaperComp.createObject(root, {
path: p
}));
}
}
}
component Wallpaper: QtObject {
required property string path
readonly property string name: path.slice(path.lastIndexOf("/") + 1, path.lastIndexOf("."))
}
Component {
id: wallpaperComp
Wallpaper {}
}
}