| 1 | import QtQuick |
| 2 | import QtQuick.Layouts |
| 3 | import Quickshell |
| 4 | import Quickshell.Wayland |
| 5 | import Quickshell.Hyprland |
| 6 | |
| 7 | // Wallpaper picker: folder chips, filter, thumbnail grid. Enter / double-click applies (stays open |
| 8 | // so you can try a few), Esc or click outside closes. Tab / Shift+Tab cycles folders. |
| 9 | PanelWindow { |
| 10 | id: win |
| 11 | property bool opened: false |
| 12 | signal closeRequested() |
| 13 | |
| 14 | visible: opened |
| 15 | implicitWidth: screen ? Math.round(screen.width * 0.7) : 1200 |
| 16 | implicitHeight: screen ? Math.round(screen.height * 0.7) : 700 |
| 17 | exclusiveZone: 0 |
| 18 | color: "transparent" |
| 19 | WlrLayershell.layer: WlrLayer.Overlay |
| 20 | WlrLayershell.namespace: "aurora-wallpaper" |
| 21 | WlrLayershell.keyboardFocus: opened ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None |
| 22 | |
| 23 | HyprlandFocusGrab { |
| 24 | windows: [win] |
| 25 | active: win.opened |
| 26 | onCleared: win.closeRequested() |
| 27 | } |
| 28 | |
| 29 | onOpenedChanged: { |
| 30 | if (!opened) return |
| 31 | filter.text = "" |
| 32 | Wall.refresh() |
| 33 | filter.forceActiveFocus() |
| 34 | } |
| 35 | |
| 36 | readonly property var shown: { |
| 37 | var q = filter.text.toLowerCase(), out = [], l = Wall.files |
| 38 | for (var i = 0; i < l.length; i++) |
| 39 | if (q === "" || l[i].substring(l[i].lastIndexOf("/") + 1).toLowerCase().indexOf(q) >= 0) out.push(l[i]) |
| 40 | return out |
| 41 | } |
| 42 | readonly property var chips: [""].concat(Wall.folders) |
| 43 | |
| 44 | function cycleFolder(d) { |
| 45 | var i = chips.indexOf(Wall.folder) |
| 46 | Wall.listFolder(chips[(i + d + chips.length) % chips.length]) |
| 47 | } |
| 48 | function name(p) { return p.substring(p.lastIndexOf("/") + 1) } |
| 49 | |
| 50 | Rectangle { |
| 51 | anchors.fill: parent |
| 52 | color: Theme.bgA |
| 53 | border.width: 2 |
| 54 | border.color: Theme.accent |
| 55 | |
| 56 | ColumnLayout { |
| 57 | anchors.fill: parent |
| 58 | anchors.margins: 12 |
| 59 | spacing: 10 |
| 60 | |
| 61 | // folder chips |
| 62 | Flow { |
| 63 | Layout.fillWidth: true |
| 64 | spacing: 4 |
| 65 | Repeater { |
| 66 | model: win.chips |
| 67 | delegate: Rectangle { |
| 68 | id: chip |
| 69 | required property var modelData |
| 70 | readonly property bool current: Wall.folder === modelData |
| 71 | width: cl.implicitWidth + 18 |
| 72 | height: 24 |
| 73 | color: current ? Theme.accent : (cm.containsMouse ? Theme.surfaceHi : "transparent") |
| 74 | border.width: 1 |
| 75 | border.color: current ? Theme.accent : Theme.line |
| 76 | Text { |
| 77 | id: cl |
| 78 | anchors.centerIn: parent |
| 79 | text: chip.modelData === "" ? "all" : chip.modelData |
| 80 | color: chip.current ? Theme.bg : Theme.fg |
| 81 | font.family: Theme.font |
| 82 | font.pixelSize: Theme.fontSize - 1 |
| 83 | font.bold: chip.current |
| 84 | } |
| 85 | MouseArea { |
| 86 | id: cm |
| 87 | anchors.fill: parent |
| 88 | hoverEnabled: true |
| 89 | cursorShape: Qt.PointingHandCursor |
| 90 | onClicked: Wall.listFolder(chip.modelData) |
| 91 | } |
| 92 | } |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | // filter + actions |
| 97 | RowLayout { |
| 98 | Layout.fillWidth: true |
| 99 | spacing: 6 |
| 100 | Rectangle { |
| 101 | Layout.fillWidth: true |
| 102 | implicitHeight: 30 |
| 103 | color: Theme.surface |
| 104 | border.width: 1 |
| 105 | border.color: filter.activeFocus ? Theme.accent : Theme.line |
| 106 | Text { |
| 107 | anchors.left: parent.left |
| 108 | anchors.leftMargin: 10 |
| 109 | anchors.verticalCenter: parent.verticalCenter |
| 110 | text: Theme.iSearch |
| 111 | color: Theme.dim |
| 112 | font.family: Theme.font |
| 113 | font.pixelSize: Theme.fontSize |
| 114 | } |
| 115 | TextInput { |
| 116 | id: filter |
| 117 | anchors.fill: parent |
| 118 | anchors.leftMargin: 32 |
| 119 | anchors.rightMargin: 10 |
| 120 | verticalAlignment: TextInput.AlignVCenter |
| 121 | color: Theme.fg |
| 122 | selectionColor: Theme.accent |
| 123 | selectedTextColor: Theme.bg |
| 124 | font.family: Theme.font |
| 125 | font.pixelSize: Theme.fontSize |
| 126 | clip: true |
| 127 | onTextChanged: grid.currentIndex = 0 |
| 128 | Keys.onEscapePressed: win.closeRequested() |
| 129 | Keys.onReturnPressed: if (grid.currentIndex >= 0) Wall.apply(win.shown[grid.currentIndex]) |
| 130 | Keys.onEnterPressed: if (grid.currentIndex >= 0) Wall.apply(win.shown[grid.currentIndex]) |
| 131 | Keys.onLeftPressed: event => { if (text === "") grid.moveCurrentIndexLeft(); else event.accepted = false } |
| 132 | Keys.onRightPressed: event => { if (text === "") grid.moveCurrentIndexRight(); else event.accepted = false } |
| 133 | Keys.onUpPressed: grid.moveCurrentIndexUp() |
| 134 | Keys.onDownPressed: grid.moveCurrentIndexDown() |
| 135 | Keys.onTabPressed: win.cycleFolder(1) |
| 136 | Keys.onBacktabPressed: win.cycleFolder(-1) |
| 137 | Keys.onPressed: event => { |
| 138 | if (event.key === Qt.Key_PageDown) { grid.currentIndex = Math.min(win.shown.length - 1, grid.currentIndex + grid.perPage); event.accepted = true } |
| 139 | else if (event.key === Qt.Key_PageUp) { grid.currentIndex = Math.max(0, grid.currentIndex - grid.perPage); event.accepted = true } |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | Text { |
| 144 | text: Wall.loading ? "..." : win.shown.length + " / " + Wall.files.length |
| 145 | color: Theme.dim |
| 146 | font.family: Theme.font |
| 147 | font.pixelSize: Theme.fontSize - 1 |
| 148 | } |
| 149 | IconBtn { icon: Theme.iShuffle; label: "random"; onClicked: Wall.random() } |
| 150 | IconBtn { icon: Theme.iClose; onClicked: win.closeRequested() } |
| 151 | } |
| 152 | |
| 153 | GridView { |
| 154 | id: grid |
| 155 | Layout.fillWidth: true |
| 156 | Layout.fillHeight: true |
| 157 | clip: true |
| 158 | readonly property int cols: Math.max(1, Math.floor(width / 240)) |
| 159 | readonly property int perPage: cols * Math.max(1, Math.floor(height / cellHeight)) |
| 160 | cellWidth: Math.floor(width / cols) |
| 161 | cellHeight: Math.round(cellWidth * 9 / 16) + 22 |
| 162 | model: win.shown |
| 163 | currentIndex: 0 |
| 164 | boundsBehavior: Flickable.StopAtBounds |
| 165 | highlightMoveDuration: 0 |
| 166 | cacheBuffer: cellHeight * 2 |
| 167 | |
| 168 | delegate: Item { |
| 169 | id: cell |
| 170 | required property var modelData |
| 171 | required property int index |
| 172 | readonly property string hash: Qt.md5(modelData) |
| 173 | readonly property bool isCurrent: Wall.current === modelData |
| 174 | readonly property bool selected: grid.currentIndex === index |
| 175 | width: grid.cellWidth |
| 176 | height: grid.cellHeight |
| 177 | |
| 178 | Rectangle { |
| 179 | anchors.fill: parent |
| 180 | anchors.margins: 4 |
| 181 | color: Theme.surface |
| 182 | border.width: cell.selected || cell.isCurrent ? 2 : 1 |
| 183 | border.color: cell.selected ? Theme.fg : (cell.isCurrent ? Theme.accent : Theme.line) |
| 184 | |
| 185 | Image { |
| 186 | id: img |
| 187 | anchors.fill: parent |
| 188 | anchors.margins: 2 |
| 189 | anchors.bottomMargin: 20 |
| 190 | fillMode: Image.PreserveAspectCrop |
| 191 | asynchronous: true |
| 192 | cache: false |
| 193 | sourceSize.width: 320 |
| 194 | sourceSize.height: 180 |
| 195 | source: Wall.ready[cell.hash] ? "file://" + Wall.thumbDir + "/" + cell.hash + ".jpg" : "" |
| 196 | } |
| 197 | Text { |
| 198 | anchors.centerIn: img |
| 199 | visible: img.status !== Image.Ready |
| 200 | text: Theme.iImage |
| 201 | color: Theme.dim |
| 202 | font.family: Theme.font |
| 203 | font.pixelSize: 22 |
| 204 | } |
| 205 | Text { |
| 206 | anchors.left: parent.left |
| 207 | anchors.right: parent.right |
| 208 | anchors.bottom: parent.bottom |
| 209 | anchors.margins: 4 |
| 210 | height: 14 |
| 211 | text: (cell.isCurrent ? Theme.iCheck + " " : "") + win.name(cell.modelData) |
| 212 | elide: Text.ElideMiddle |
| 213 | color: cell.isCurrent ? Theme.accent : Theme.dim |
| 214 | font.family: Theme.font |
| 215 | font.pixelSize: Theme.fontSize - 2 |
| 216 | } |
| 217 | } |
| 218 | MouseArea { |
| 219 | anchors.fill: parent |
| 220 | cursorShape: Qt.PointingHandCursor |
| 221 | onClicked: grid.currentIndex = cell.index |
| 222 | onDoubleClicked: Wall.apply(cell.modelData) |
| 223 | } |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | Text { |
| 228 | Layout.fillWidth: true |
| 229 | text: "enter apply tab folder arrows move esc close " + Theme.iImage + " " + win.name(Wall.current) |
| 230 | elide: Text.ElideRight |
| 231 | color: Theme.dim |
| 232 | font.family: Theme.font |
| 233 | font.pixelSize: Theme.fontSize - 2 |
| 234 | } |
| 235 | } |
| 236 | } |
| 237 | } |