nexoniarz-config-nixos/dotfiles/quickshell/LeftFlyout.qml
Nexoniarz ea7fefbba8 Fix wallpaper persistence, tray icons, Booru bugs, session launcher
- Persist last-set wallpaper and restore it via exec-once, since
  hyprpaper/mpvpaper start every session with nothing loaded
- Fix Vesktop's system tray icon rendering as a broken texture: only
  resolve tray icons through the icon theme when they're actual names,
  not raw pixmap-sourced image:// URLs
- Launch Hyprland via start-hyprland (its official watchdog launcher)
  instead of the raw binary, removing the startup warning and adding
  hang recovery
- Fix Booru panel: Lewds toggle now actually persists, tag autocomplete
  no longer drops/races overlapping requests, and pagination wraps
  instead of overflowing off-screen
- Remove the redundant gap between windows and sidebars/tiles
  (gaps_in/gaps_out, and duplicate flyout margins stacked on top of
  Hyprland's automatic exclusive-zone inset)
2026-08-30 15:48:06 +02:00

1838 lines
82 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import Quickshell.Widgets
Rectangle {
id: root
property string activePanel: ""
property var screen: null
signal requestClose()
width: 320
implicitHeight: header.height + loader.item.implicitHeight + 16
color: Theme.bg
border.width: 2
border.color: Theme.border
property var titles: ({
theme: "Theme",
settings: "Displays",
wallpapers: "Wallpapers",
booru: "Booru",
cursor: "Cursor"
})
Item {
id: header
width: parent.width
height: 40
Text {
anchors.left: parent.left
anchors.leftMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: root.titles[root.activePanel] || ""
color: Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 14
font.bold: true
}
CloseButton {
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
onClicked: root.requestClose()
}
}
Rectangle { anchors.top: header.bottom; width: parent.width; height: 1; color: Theme.border }
Loader {
id: loader
y: header.height + 8
x: 12
width: parent.width - 24
sourceComponent: {
switch (root.activePanel) {
case "theme": return themePanel;
case "settings": return settingsPanel;
case "wallpapers": return wallpapersPanel;
case "booru": return booruPanel;
case "cursor": return cursorPanel;
default: return emptyPanel;
}
}
}
Component {
id: emptyPanel
Item { implicitHeight: 1 }
}
// --- Theme --------------------------------------------------------------
// Everything here just calls Theme.setAccent/setDark/randomAccent —
// Theme.qml itself owns the disk persistence, so there's nothing to
// wire up here beyond the picker UI.
Component {
id: themePanel
Column {
width: loader.width
spacing: 16
Column {
width: parent.width
spacing: 8
Text {
text: "ACCENT COLOR"
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 9
font.bold: true
}
Flow {
width: parent.width
spacing: 8
Repeater {
model: Theme.accentNames
delegate: Rectangle {
property string name: modelData
property color swatch: Theme.accentPalette[name]
width: 40; height: 40
color: swatch
border.width: Theme.accentName === name ? 3 : 1
border.color: Theme.accentName === name ? Theme.fg : Theme.border
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: Theme.setAccent(name)
}
}
}
Rectangle {
width: 40; height: 40
color: Theme.bgAlt
border.width: 1
border.color: Theme.border
Text {
anchors.centerIn: parent
text: "RND"
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 9
font.bold: true
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: Theme.randomAccent()
}
}
}
}
Column {
width: parent.width
spacing: 8
Text {
text: "MODE"
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 9
font.bold: true
}
Row {
spacing: 6
Repeater {
model: [
{ label: "Dark", value: true },
{ label: "Light", value: false }
]
delegate: Rectangle {
property bool val: modelData.value
width: 90; height: 30
color: Theme.dark === val ? Theme.accent : (modeMa.containsMouse ? Theme.bgAlt : "transparent")
border.width: 1
border.color: Theme.dark === val ? Theme.accent : Theme.border
Text {
anchors.centerIn: parent
text: modelData.label
color: Theme.dark === val ? Theme.bg : Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 12
font.bold: true
}
MouseArea {
id: modeMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: Theme.setDark(val)
}
}
}
}
}
Text {
text: "Applies everywhere immediately. GTK apps (Thunar) follow separately\nand may need a restart to fully match."
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 9
wrapMode: Text.WordWrap
width: parent.width
}
}
}
// --- Displays ---------------------------------------------------------
// Mode/scale/rotation are edited locally per monitor (starting from its
// current live values) and only actually applied when "Apply" is
// clicked, via `hyprctl keyword monitor ...` — same mechanism Hyprland
// itself uses, so it's an immediate, real change, not a config-file edit.
Component {
id: settingsPanel
Column {
width: loader.width
spacing: 16
Text {
visible: Hyprland.monitors.values.length === 0
text: "No monitor data"
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 12
}
Repeater {
model: Hyprland.monitors
delegate: Column {
id: monDelegate
width: loader.width
spacing: 8
property var mon: modelData
property var ipc: mon.lastIpcObject || ({})
property var modes: ipc.availableModes || []
// Picks the mode matching both the current resolution
// AND the closest actual refresh rate — matching on
// resolution alone (and just taking whichever mode
// happened to be listed first for it) was showing
// "60Hz" as "current" regardless of what the monitor
// was really running at, since 60Hz is what this
// monitor's availableModes lists first per resolution.
property string selectedMode: {
var cur = mon.width + "x" + mon.height;
var curRate = ipc.refreshRate || 0;
var best = "";
var bestDiff = 999999;
for (var i = 0; i < monDelegate.modes.length; i++) {
var m = monDelegate.modes[i];
if (m.indexOf(cur + "@") !== 0) continue;
var rate = parseFloat(m.substring(m.indexOf("@") + 1));
var diff = Math.abs(rate - curRate);
if (diff < bestDiff) { bestDiff = diff; best = m; }
}
if (best !== "") return best;
return monDelegate.modes.length > 0 ? monDelegate.modes[0] : (cur + "@60.00Hz");
}
property real selectedScale: mon.scale
property int selectedTransform: ipc.transform || 0
property bool modesOpen: false
Process { id: applyProc; command: [] }
Text {
text: mon.name + (mon.focused ? " (focused)" : "")
color: mon.focused ? Theme.accent : Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 13
font.bold: true
}
Text {
text: ipc.description || ""
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 10
}
// -- Mode picker --
Text {
text: "Resolution / refresh rate"
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 9
font.bold: true
}
Rectangle {
width: monDelegate.width
height: 30
color: modeMa.containsMouse ? Theme.bgAlt : "transparent"
border.width: 1
border.color: Theme.border
Text {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: monDelegate.selectedMode
color: Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 12
}
Text {
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: monDelegate.modesOpen ? "▲" : "▼"
color: Theme.fgDim
font.pixelSize: 10
}
MouseArea {
id: modeMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: monDelegate.modesOpen = !monDelegate.modesOpen
}
}
Flickable {
visible: monDelegate.modesOpen
width: monDelegate.width
// Bounded by the actual content height (never a
// self-reference back to this Flickable's own
// height — that circular binding was collapsing
// this to 0 and silently hiding every mode but the
// current one) capped at 160 so a long mode list
// scrolls instead of growing the flyout forever.
height: Math.min(160, modeListCol.height)
contentHeight: modeListCol.height
clip: true
Column {
id: modeListCol
width: monDelegate.width
Repeater {
model: monDelegate.modes
delegate: Rectangle {
width: monDelegate.width
height: 26
color: modeItemMa.containsMouse ? Theme.bgAlt : "transparent"
border.width: 1
border.color: Theme.borderDim
Text {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: modelData
color: monDelegate.selectedMode === modelData ? Theme.accent : Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 11
}
MouseArea {
id: modeItemMa
anchors.fill: parent
hoverEnabled: true
onClicked: {
monDelegate.selectedMode = modelData;
monDelegate.modesOpen = false;
}
}
}
}
}
}
// -- Scale --
Text {
text: "Scale: " + monDelegate.selectedScale.toFixed(2) + "x"
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 9
font.bold: true
}
Row {
spacing: 6
Rectangle {
width: 30; height: 26
color: scaleDownMa.containsMouse ? Theme.bgAlt : "transparent"
border.width: 1
border.color: Theme.border
Text { anchors.centerIn: parent; text: "-"; color: Theme.fg; font.pixelSize: 14 }
MouseArea {
id: scaleDownMa
anchors.fill: parent
hoverEnabled: true
onClicked: monDelegate.selectedScale = Math.max(0.5, Math.round((monDelegate.selectedScale - 0.05) * 100) / 100)
}
}
Rectangle {
width: 30; height: 26
color: scaleUpMa.containsMouse ? Theme.bgAlt : "transparent"
border.width: 1
border.color: Theme.border
Text { anchors.centerIn: parent; text: "+"; color: Theme.fg; font.pixelSize: 14 }
MouseArea {
id: scaleUpMa
anchors.fill: parent
hoverEnabled: true
onClicked: monDelegate.selectedScale = Math.min(3.0, Math.round((monDelegate.selectedScale + 0.05) * 100) / 100)
}
}
}
// -- Rotation --
Text {
text: "Rotation"
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 9
font.bold: true
}
Row {
spacing: 6
Repeater {
model: [0, 1, 2, 3]
delegate: Rectangle {
property int t: modelData
width: 50; height: 26
color: monDelegate.selectedTransform === t ? Theme.accent : (rotMa.containsMouse ? Theme.bgAlt : "transparent")
border.width: 1
border.color: monDelegate.selectedTransform === t ? Theme.accent : Theme.border
Text {
anchors.centerIn: parent
text: [0, 90, 180, 270][t] + "°"
color: monDelegate.selectedTransform === t ? Theme.bg : Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 11
}
MouseArea {
id: rotMa
anchors.fill: parent
hoverEnabled: true
onClicked: monDelegate.selectedTransform = t
}
}
}
}
// -- Apply --
Rectangle {
width: monDelegate.width
height: 30
color: applyMa.containsMouse ? Theme.bgAlt : "transparent"
border.width: 2
border.color: Theme.accent
Text {
anchors.centerIn: parent
text: "Apply"
color: Theme.accent
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 12
font.bold: true
}
MouseArea {
id: applyMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
applyProc.command = ["display-set", monDelegate.mon.name,
monDelegate.selectedMode, String(monDelegate.mon.x), String(monDelegate.mon.y),
monDelegate.selectedScale.toFixed(2), String(monDelegate.selectedTransform)];
applyProc.running = true;
}
}
}
Rectangle { width: monDelegate.width; height: 1; color: Theme.border }
}
}
}
}
// --- Wallpapers -------------------------------------------------------
Component {
id: wallpapersPanel
Column {
id: wpCol
width: loader.width
spacing: 14
property var staticImages: []
property var animatedImages: []
property string current: ""
// $HOME/Wallpapers rather than $HOME/Pictures/Wallpapers: this
// system's locale (pl_PL) means the real Pictures dir is
// ~/Obrazy, not ~/Pictures — living under $HOME sidesteps that.
property string wallpapersDir: Quickshell.env("HOME") + "/Wallpapers"
// Real disk-backed persistence — PersistentProperties turned
// out not to actually survive a Quickshell restart (verified:
// no backing file for it exists anywhere on disk), so this
// reads/writes its own small JSON file under Quickshell's
// state dir instead.
Item {
id: wpSettings
property string fitMode: "cover" // static only: cover|contain
property string muted: "1" // animated only: 1|0
property string speed: "1.0" // animated only
property string autoPause: "1" // animated only: 1|0
property string stateFile: Quickshell.stateDir + "/wallpaper-settings.json"
function save() {
var json = JSON.stringify({
fitMode: wpSettings.fitMode,
muted: wpSettings.muted,
speed: wpSettings.speed,
autoPause: wpSettings.autoPause
});
wpSaveProc.command = ["sh", "-c",
"mkdir -p \"$(dirname \"$2\")\" && printf '%s' \"$1\" > \"$2\"",
"--", json, wpSettings.stateFile];
wpSaveProc.running = true;
}
Process { id: wpSaveProc }
Process {
id: wpLoadProc
command: ["cat", wpSettings.stateFile]
stdout: StdioCollector {
onStreamFinished: {
try {
var d = JSON.parse(text);
if (d.fitMode) wpSettings.fitMode = d.fitMode;
if (d.muted) wpSettings.muted = d.muted;
if (d.speed) wpSettings.speed = d.speed;
if (d.autoPause) wpSettings.autoPause = d.autoPause;
} catch (e) { /* no state file yet — defaults stand */ }
}
}
}
Component.onCompleted: wpLoadProc.running = true
}
function refresh() {
staticListProc.running = true;
animatedListProc.running = true;
}
function apply(path) {
setProc.command = ["wallpaper-set", path, wpSettings.fitMode,
wpSettings.muted, wpSettings.speed, wpSettings.autoPause];
setProc.running = true;
wpCol.current = path;
}
function deleteWallpaper(path) {
// Moves to the freedesktop trash (same as Thunar's own
// delete) rather than `rm`, which would permanently
// destroy the file with zero undo — there's no confirm
// dialog on this button, so it needs to be recoverable.
deleteProc.command = ["wallpaper-delete", path];
deleteProc.running = true;
}
Process {
id: staticListProc
command: ["find", wpCol.wallpapersDir, "-maxdepth", "1", "-type", "f",
"(", "-iname", "*.jpg", "-o", "-iname", "*.jpeg", "-o", "-iname", "*.png",
"-o", "-iname", "*.webp", "-o", "-iname", "*.bmp", ")"]
stdout: StdioCollector {
onStreamFinished: {
wpCol.staticImages = text.split("\n").filter((s) => s.length > 0);
}
}
}
Process {
id: animatedListProc
command: ["find", wpCol.wallpapersDir, "-maxdepth", "1", "-type", "f",
"(", "-iname", "*.mp4", "-o", "-iname", "*.webm", "-o", "-iname", "*.mkv",
"-o", "-iname", "*.mov", "-o", "-iname", "*.gif", ")"]
stdout: StdioCollector {
onStreamFinished: {
wpCol.animatedImages = text.split("\n").filter((s) => s.length > 0);
}
}
}
Component.onCompleted: wpCol.refresh()
Process { id: setProc; command: [] }
Process { id: deleteProc; onExited: wpCol.refresh() }
Text {
text: "Add images/videos to " + wpCol.wallpapersDir
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 10
wrapMode: Text.WordWrap
width: wpCol.width
}
// -- Settings --
Column {
width: wpCol.width
spacing: 8
Text {
text: "SETTINGS"
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 9
font.bold: true
}
Rectangle {
width: wpCol.width
height: 30
color: shuffleMa.containsMouse ? Theme.bgAlt : "transparent"
border.width: 1
border.color: Theme.accent
Text {
anchors.centerIn: parent
text: "Shuffle"
color: Theme.accent
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 12
font.bold: true
}
MouseArea {
id: shuffleMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
var all = wpCol.staticImages.concat(wpCol.animatedImages);
if (all.length === 0) return;
wpCol.apply(all[Math.floor(Math.random() * all.length)]);
}
}
}
Text {
text: "Fit mode (static images)"
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 9
}
Row {
spacing: 6
Repeater {
model: ["cover", "contain"]
delegate: Rectangle {
property string mode: modelData
width: 76; height: 26
color: wpSettings.fitMode === mode ? Theme.accent : (fitMa.containsMouse ? Theme.bgAlt : "transparent")
border.width: 1
border.color: wpSettings.fitMode === mode ? Theme.accent : Theme.border
Text {
anchors.centerIn: parent
text: mode
color: wpSettings.fitMode === mode ? Theme.bg : Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 11
}
MouseArea {
id: fitMa
anchors.fill: parent
hoverEnabled: true
onClicked: {
wpSettings.fitMode = mode;
wpSettings.save();
if (wpCol.current !== "" && wpCol.staticImages.indexOf(wpCol.current) !== -1) wpCol.apply(wpCol.current);
}
}
}
}
}
Text {
text: "Animated wallpaper playback"
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 9
}
Row {
spacing: 6
Rectangle {
width: 60; height: 26
color: wpSettings.muted === "1" ? Theme.accent : (muteMa.containsMouse ? Theme.bgAlt : "transparent")
border.width: 1
border.color: wpSettings.muted === "1" ? Theme.accent : Theme.border
Text {
anchors.centerIn: parent
text: "Muted"
color: wpSettings.muted === "1" ? Theme.bg : Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 11
}
MouseArea {
id: muteMa
anchors.fill: parent
hoverEnabled: true
onClicked: { wpSettings.muted = wpSettings.muted === "1" ? "0" : "1"; wpSettings.save(); }
}
}
Rectangle {
width: 110; height: 26
color: pauseMa.containsMouse ? Theme.bgAlt : "transparent"
border.width: 1
border.color: wpSettings.autoPause === "1" ? Theme.accent : Theme.border
Text {
anchors.centerIn: parent
text: "Auto-pause"
color: wpSettings.autoPause === "1" ? Theme.accent : Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 11
}
MouseArea {
id: pauseMa
anchors.fill: parent
hoverEnabled: true
onClicked: { wpSettings.autoPause = wpSettings.autoPause === "1" ? "0" : "1"; wpSettings.save(); }
}
}
}
Row {
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
text: "Speed: " + parseFloat(wpSettings.speed).toFixed(2) + "x"
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 10
}
Rectangle {
width: 26; height: 26
color: speedDownMa.containsMouse ? Theme.bgAlt : "transparent"
border.width: 1
border.color: Theme.border
Text { anchors.centerIn: parent; text: "-"; color: Theme.fg; font.pixelSize: 14 }
MouseArea {
id: speedDownMa
anchors.fill: parent
hoverEnabled: true
onClicked: { wpSettings.speed = String(Math.max(0.25, Math.round((parseFloat(wpSettings.speed) - 0.25) * 100) / 100)); wpSettings.save(); }
}
}
Rectangle {
width: 26; height: 26
color: speedUpMa.containsMouse ? Theme.bgAlt : "transparent"
border.width: 1
border.color: Theme.border
Text { anchors.centerIn: parent; text: "+"; color: Theme.fg; font.pixelSize: 14 }
MouseArea {
id: speedUpMa
anchors.fill: parent
hoverEnabled: true
onClicked: { wpSettings.speed = String(Math.min(4.0, Math.round((parseFloat(wpSettings.speed) + 0.25) * 100) / 100)); wpSettings.save(); }
}
}
}
Text {
visible: wpSettings.muted !== "1" || parseFloat(wpSettings.speed) !== 1.0 || wpSettings.autoPause !== "1"
text: "Applies next time you pick an animated wallpaper (or re-click the current one)."
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 9
wrapMode: Text.WordWrap
width: wpCol.width
}
Rectangle { width: wpCol.width; height: 1; color: Theme.border }
}
Text {
visible: wpCol.staticImages.length === 0 && wpCol.animatedImages.length === 0
text: "No wallpapers found"
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 12
}
Column {
visible: wpCol.staticImages.length > 0
width: wpCol.width
spacing: 8
Text {
text: "STATIC"
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 9
font.bold: true
}
Flow {
width: wpCol.width
spacing: 8
Repeater {
model: wpCol.staticImages
delegate: Rectangle {
id: staticTile
property string path: modelData
width: 92
height: 92
color: Theme.bgAlt
border.width: wpCol.current === path ? 2 : 1
border.color: wpCol.current === path ? Theme.accent : Theme.borderDim
HoverHandler { id: staticHover }
Image {
anchors.fill: parent
anchors.margins: 2
source: "file://" + path
fillMode: Image.PreserveAspectCrop
asynchronous: true
clip: true
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: wpCol.apply(path)
}
DeleteTileButton {
visible: staticHover.hovered
onClicked: wpCol.deleteWallpaper(staticTile.path)
}
}
}
}
}
Column {
visible: wpCol.animatedImages.length > 0
width: wpCol.width
spacing: 8
Text {
text: "ANIMATED"
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 9
font.bold: true
}
Flow {
width: wpCol.width
spacing: 8
Repeater {
model: wpCol.animatedImages
delegate: Rectangle {
id: animTile
property string path: modelData
property string fileName: path.split("/").pop()
width: 92
height: 92
color: Theme.bgAlt
border.width: wpCol.current === path ? 2 : 1
border.color: wpCol.current === path ? Theme.accent : Theme.borderDim
HoverHandler { id: animHover }
// No live thumbnail for video files — a play
// glyph + filename instead of the expense/
// complexity of extracting a preview frame.
Text {
anchors.centerIn: parent
text: "▶"
color: Theme.fgDim
font.pixelSize: 22
anchors.verticalCenterOffset: -10
}
Text {
anchors.bottom: parent.bottom
anchors.bottomMargin: 6
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 4
anchors.rightMargin: 4
horizontalAlignment: Text.AlignHCenter
text: fileName
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 9
elide: Text.ElideMiddle
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: wpCol.apply(path)
}
DeleteTileButton {
visible: animHover.hovered
onClicked: wpCol.deleteWallpaper(animTile.path)
}
}
}
}
}
}
}
// --- Booru (Danbooru only for now — Gelbooru's API needs an account
// API key which hasn't been set up) ------------------------------------
// Defaults to safe content (rating:general appended to the tag query);
// the Lewds toggle removes that restriction. Clicking a result opens a
// bigger preview rather than downloading immediately — Download is an
// explicit separate action, and "Set as Wallpaper" only appears once
// the file actually exists locally.
Component {
id: booruPanel
Column {
id: booruCol
width: loader.width
spacing: 10
property string query: ""
// Real disk-backed persistence — PersistentProperties doesn't
// actually survive a Quickshell restart (verified: no backing
// file for it exists anywhere on disk), so this reads/writes
// its own small state file instead, same as wpSettings above.
Item {
id: booruSettings
property string lewds: "0"
property string source: "danbooru"
property string stateFile: Quickshell.stateDir + "/booru-settings.json"
function save() {
var json = JSON.stringify({ lewds: booruSettings.lewds, source: booruSettings.source });
booruSaveProc.command = ["sh", "-c",
"mkdir -p \"$(dirname \"$2\")\" && printf '%s' \"$1\" > \"$2\"",
"--", json, booruSettings.stateFile];
booruSaveProc.running = true;
}
Process { id: booruSaveProc }
Process {
id: booruLoadProc
command: ["cat", booruSettings.stateFile]
stdout: StdioCollector {
onStreamFinished: {
try {
var d = JSON.parse(text);
if (d.lewds) booruSettings.lewds = d.lewds;
if (d.source) booruSettings.source = d.source;
} catch (e) { /* no state file yet — defaults stand */ }
}
}
}
Component.onCompleted: booruLoadProc.running = true
}
readonly property bool lewds: booruSettings.lewds === "1"
// -- Multi-source support --
// Gelbooru and Rule34.xxx both now require a registered
// account + API key to use their APIs at all (verified live,
// both return an auth error with no key) — skipped for that
// reason, same call as Gelbooru earlier. These four don't
// need any key.
readonly property var sources: ({
danbooru: { label: "Danbooru", safeTag: "rating:general", hasCount: true, hasAutocomplete: true },
e621: { label: "e621", safeTag: "rating:s", hasCount: false, hasAutocomplete: false },
yandere: { label: "Yande.re", safeTag: "rating:safe", hasCount: false, hasAutocomplete: false },
konachan: { label: "Konachan", safeTag: "rating:safe", hasCount: false, hasAutocomplete: false }
})
readonly property var sourceNames: ["danbooru", "e621", "yandere", "konachan"]
readonly property string userAgent: "QuickshellBooruPanel/1.0 (by nexoniarz)"
// Every source has a different JSON shape — this maps whatever
// came back into one common shape so the rest of the panel
// (grid, detail view, download) doesn't need to know which
// source it came from.
function normalizePost(raw, source) {
if (source === "danbooru") {
if (!raw.file_url || !raw.preview_file_url) return null;
return {
id: raw.id,
previewUrl: raw.preview_file_url,
largeUrl: raw.large_file_url || raw.file_url,
fileUrl: raw.file_url,
fileExt: raw.file_ext || "jpg",
rating: raw.rating || "g",
tagString: raw.tag_string || "",
width: raw.image_width || 0,
height: raw.image_height || 0
};
}
if (source === "e621") {
if (!raw.file || !raw.file.url || !raw.preview || !raw.preview.url) return null;
var tags = [];
if (raw.tags) {
for (var cat in raw.tags) tags = tags.concat(raw.tags[cat]);
}
var r1 = raw.rating === "e" ? "e" : (raw.rating === "q" ? "q" : "g");
return {
id: raw.id,
previewUrl: raw.preview.url,
largeUrl: (raw.sample && raw.sample.has && raw.sample.url) ? raw.sample.url : raw.file.url,
fileUrl: raw.file.url,
fileExt: raw.file.ext || "jpg",
rating: r1,
tagString: tags.join(" "),
width: raw.file.width || 0,
height: raw.file.height || 0
};
}
if (source === "yandere" || source === "konachan") {
if (!raw.file_url || !raw.preview_url) return null;
var r2 = raw.rating === "e" ? "e" : (raw.rating === "q" ? "q" : "g");
return {
id: raw.id,
previewUrl: raw.preview_url,
largeUrl: raw.sample_url || raw.file_url,
fileUrl: raw.file_url,
fileExt: raw.file_ext || "jpg",
rating: r2,
tagString: raw.tags || "",
width: raw.width || 0,
height: raw.height || 0
};
}
return null;
}
property var results: []
property var selected: null
property string downloadedPath: ""
property bool downloading: false
property bool searching: false
property string downloadTarget: ""
property bool previewReady: false
// Video formats obviously can't decode as an Image — but this
// Qt build also can't decode webp (confirmed live: "Unsupported
// image format" even for a plain static one), so it gets the
// same "no preview, download to view" treatment.
readonly property bool selectedNoPreview: {
if (!booruCol.selected) return false;
var ext = (booruCol.selected.fileExt || "").toLowerCase();
return ["mp4", "webm", "mov", "avi", "webp"].indexOf(ext) !== -1;
}
property string previewLocalPath: ""
property int page: 1
// Whether the last fetch came back full — if so there's
// probably a next page; Danbooru doesn't expose a total count
// on this endpoint, so "did we get a full page" is the signal.
property bool hasNextPage: false
// $HOME/Wallpapers, same folder the Wallpapers panel reads from
// — a downloaded post shows up there automatically.
property string wallpapersDir: Quickshell.env("HOME") + "/Wallpapers"
// cdn.donmai.us returns 403 to QML's Image networking (its
// default request looks bot-like to whatever's fronting the
// CDN) even though plain curl works fine — so thumbnails and
// previews are fetched via curl into a local cache dir first,
// and Image only ever points at the local file.
property string cacheDir: Quickshell.cacheDir + "/booru"
// Sized to the actual screen instead of a fixed count, so a
// page of results never grows the flyout past the bottom of
// the screen (that was cutting images off entirely rather than
// just needing a scrollbar, since the popup has no scroll area).
readonly property int columns: 3
readonly property int tileSlot: 100 // 92px tile + 8px spacing
property int visibleRows: {
var screenH = (root.screen && root.screen.height) ? root.screen.height : 1080;
// Reserve space for the taskbar, the flyout header, the
// search/lewds/source rows above the grid, and margins.
var reserved = 340;
return Math.max(2, Math.floor((screenH - reserved) / booruCol.tileSlot));
}
property int pageSize: booruCol.columns * booruCol.visibleRows
// -1 = not known yet (count request still in flight / failed).
property int totalCount: -1
property int totalPages: booruCol.totalCount >= 0
? Math.max(1, Math.ceil(booruCol.totalCount / booruCol.pageSize)) : -1
function buildTagsParam() {
var tagParts = booruCol.query.trim().length > 0
? booruCol.query.trim().split(/\s+/).map(encodeURIComponent)
: [];
if (!booruCol.lewds) tagParts.push(booruCol.sources[booruSettings.source].safeTag);
return tagParts.join("+");
}
function doSearch() {
booruCol.page = 1;
booruCol.totalCount = -1;
var meta = booruCol.sources[booruSettings.source];
if (meta.hasCount) {
var tagsParam = booruCol.buildTagsParam();
countProc.command = ["curl", "-s", "-A", booruCol.userAgent,
"https://danbooru.donmai.us/counts/posts.json" + (tagsParam ? "?tags=" + tagsParam : "")];
countProc.running = true;
}
booruCol.fetchPage();
}
// -- Tag autocomplete --
// Suggests based on whatever's after the last space, so
// completing one tag doesn't disturb tags already typed
// before it.
property var suggestions: []
Timer {
id: autocompleteTimer
interval: 250
repeat: false
onTriggered: booruCol.fetchSuggestions()
}
function currentPartialTag() {
var text = booruCol.query;
var lastSpace = text.lastIndexOf(" ");
return (lastSpace >= 0 ? text.substring(lastSpace + 1) : text).trim();
}
function fetchSuggestions() {
if (!booruCol.sources[booruSettings.source].hasAutocomplete) {
booruCol.suggestions = [];
return;
}
var partial = booruCol.currentPartialTag();
if (partial.length < 2) {
booruCol.suggestions = [];
return;
}
var url = "https://danbooru.donmai.us/autocomplete.json?search%5Bquery%5D="
+ encodeURIComponent(partial) + "&search%5Btype%5D=tag_query&limit=8";
// If a previous keystroke's request is still in flight (curl
// hasn't returned yet), reassigning .command and setting
// .running = true on an already-running Process is a no-op
// — running:true -> true is not a change, so nothing
// restarts and the newer query silently never gets sent.
// That's the "sometimes works, sometimes not" flakiness:
// explicitly stop it first so the new request actually
// fires.
if (autocompleteProc.running) autocompleteProc.running = false;
autocompleteProc.requestedPartial = partial;
autocompleteProc.command = ["curl", "-s", "-A", booruCol.userAgent, url];
autocompleteProc.running = true;
}
function applySuggestion(value) {
var text = booruCol.query;
var lastSpace = text.lastIndexOf(" ");
var prefix = lastSpace >= 0 ? text.substring(0, lastSpace + 1) : "";
booruCol.query = prefix + value + " ";
queryField.text = booruCol.query;
queryField.cursorPosition = queryField.text.length;
booruCol.suggestions = [];
queryField.forceActiveFocus();
}
function fetchPage() {
var tagsParam = booruCol.buildTagsParam();
var tagsQuery = tagsParam ? "&tags=" + tagsParam : "";
var url = "";
switch (booruSettings.source) {
case "danbooru":
url = "https://danbooru.donmai.us/posts.json?limit=" + booruCol.pageSize + "&page=" + booruCol.page + tagsQuery;
break;
case "e621":
url = "https://e621.net/posts.json?limit=" + booruCol.pageSize + "&page=" + booruCol.page + tagsQuery;
break;
case "yandere":
url = "https://yande.re/post.json?limit=" + booruCol.pageSize + "&page=" + booruCol.page + tagsQuery;
break;
case "konachan":
url = "https://konachan.com/post.json?limit=" + booruCol.pageSize + "&page=" + booruCol.page + tagsQuery;
break;
}
searchProc.requestedSource = booruSettings.source;
searchProc.command = ["curl", "-s", "-A", booruCol.userAgent, url];
booruCol.searching = true;
booruCol.results = [];
searchProc.running = true;
}
Process { id: mkdirProc; command: ["mkdir", "-p", booruCol.cacheDir] }
Component.onCompleted: {
mkdirProc.running = true;
booruCol.doSearch();
}
Process {
id: searchProc
property string requestedSource: ""
stdout: StdioCollector {
onStreamFinished: {
var rawPosts = [];
try {
var data = JSON.parse(text);
if (searchProc.requestedSource === "e621") {
rawPosts = (data && Array.isArray(data.posts)) ? data.posts : [];
} else {
rawPosts = Array.isArray(data) ? data : [];
}
} catch (e) { rawPosts = []; }
var posts = [];
for (var i = 0; i < rawPosts.length; i++) {
var n = booruCol.normalizePost(rawPosts[i], searchProc.requestedSource);
if (n) posts.push(n);
}
booruCol.hasNextPage = booruCol.totalPages >= 0
? booruCol.page < booruCol.totalPages
: posts.length >= booruCol.pageSize;
if (posts.length === 0) {
booruCol.searching = false;
booruCol.results = [];
return;
}
var cmd = ["curl", "-s"];
for (var j = 0; j < posts.length; j++) {
posts[j].localThumb = booruCol.cacheDir + "/thumb_" + searchProc.requestedSource + "_" + posts[j].id + ".jpg";
cmd.push(posts[j].previewUrl);
cmd.push("-o");
cmd.push(posts[j].localThumb);
}
thumbProc.pendingResults = posts;
thumbProc.command = cmd;
thumbProc.running = true;
}
}
}
Process {
id: thumbProc
property var pendingResults: []
onExited: {
booruCol.searching = false;
booruCol.results = thumbProc.pendingResults;
}
}
Process {
id: countProc
stdout: StdioCollector {
onStreamFinished: {
try {
var d = JSON.parse(text);
booruCol.totalCount = d.counts.posts;
} catch (e) { booruCol.totalCount = -1; }
}
}
}
Process {
id: autocompleteProc
property string requestedPartial: ""
stdout: StdioCollector {
onStreamFinished: {
// Drop stale responses: even with the running-guard
// above, a slow request can still resolve after a
// faster, newer one — without this check whichever
// curl happens to finish last wins, regardless of
// which one actually matches what's typed now.
if (autocompleteProc.requestedPartial !== booruCol.currentPartialTag()) return;
try {
var d = JSON.parse(text);
booruCol.suggestions = Array.isArray(d) ? d : [];
} catch (e) { booruCol.suggestions = []; }
}
}
}
Process {
id: previewProc
onExited: (code) => {
if (code === 0) booruCol.previewReady = true;
}
}
Process {
id: downloadProc
onExited: (code) => {
booruCol.downloading = false;
if (code === 0) booruCol.downloadedPath = booruCol.downloadTarget;
}
}
Process { id: setWpProc; command: [] }
// -- Search bar --
Row {
width: booruCol.width
spacing: 6
Rectangle {
width: parent.width - 66
height: 30
color: Theme.bgAlt
border.width: 1
border.color: Theme.border
TextInput {
id: queryField
anchors.fill: parent
anchors.margins: 8
color: Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 12
text: booruCol.query
onTextChanged: {
booruCol.query = text;
autocompleteTimer.restart();
}
Keys.onReturnPressed: {
booruCol.suggestions = [];
booruCol.doSearch();
}
Keys.onEscapePressed: booruCol.suggestions = []
}
}
Rectangle {
width: 60
height: 30
color: searchMa.containsMouse ? Theme.bgAlt : "transparent"
border.width: 1
border.color: Theme.accent
Text {
anchors.centerIn: parent
text: "Search"
color: Theme.accent
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 11
}
MouseArea {
id: searchMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: { booruCol.suggestions = []; booruCol.doSearch(); }
}
}
}
Column {
visible: booruCol.suggestions.length > 0
width: booruCol.width
spacing: 2
Repeater {
model: booruCol.suggestions
delegate: Rectangle {
property var sug: modelData
width: booruCol.width
height: 24
color: sugMa.containsMouse ? Theme.bgAlt : Theme.bgAlt2
border.width: 1
border.color: Theme.borderDim
Text {
anchors.left: parent.left
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: sug.label
color: Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 11
}
Text {
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: sug.post_count
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 10
}
MouseArea {
id: sugMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: booruCol.applySuggestion(sug.value)
}
}
}
}
Row {
spacing: 8
Rectangle {
width: 80
height: 26
color: booruCol.lewds ? Theme.danger : (lewdMa.containsMouse ? Theme.bgAlt : "transparent")
border.width: 1
border.color: Theme.danger
Text {
anchors.centerIn: parent
text: booruCol.lewds ? "Lewds: ON" : "Lewds"
color: booruCol.lewds ? Theme.bg : Theme.danger
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 11
font.bold: true
}
MouseArea {
id: lewdMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
// save() here, not just the property assignment:
// this was the actual persistence bug — toggling
// Lewds never wrote to disk on its own, only the
// source-switcher below called save(). So Lewds
// only ever "stuck" as a side effect of also
// switching source afterward, and turning it
// back off never stuck at all — reopening Booru
// always reloaded whatever was last written that
// way instead of the toggle's actual state.
booruSettings.lewds = booruCol.lewds ? "0" : "1";
booruSettings.save();
booruCol.doSearch();
}
}
}
}
Row {
spacing: 6
Repeater {
model: booruCol.sourceNames
delegate: Rectangle {
property string src: modelData
width: 62; height: 22
color: booruSettings.source === src ? Theme.accent : (srcMa.containsMouse ? Theme.bgAlt : "transparent")
border.width: 1
border.color: booruSettings.source === src ? Theme.accent : Theme.border
Text {
anchors.centerIn: parent
text: booruCol.sources[src].label
color: booruSettings.source === src ? Theme.bg : Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 9
}
MouseArea {
id: srcMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (booruSettings.source === src) return;
booruSettings.source = src;
booruSettings.save();
booruCol.selected = null;
booruCol.suggestions = [];
booruCol.doSearch();
}
}
}
}
}
// -- Grid / detail --
Loader {
width: booruCol.width
sourceComponent: booruCol.selected ? booruDetail : booruGrid
}
Component {
id: booruGrid
Column {
width: booruCol.width
spacing: 8
Text {
visible: booruCol.searching
text: "Searching..."
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 12
}
Text {
visible: !booruCol.searching && booruCol.results.length === 0
text: "No results"
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 12
}
Flow {
width: booruCol.width
spacing: 8
Repeater {
model: booruCol.results
delegate: Rectangle {
property var post: modelData
width: 92
height: 92
color: Theme.bgAlt
border.width: 1
border.color: (post.rating === "e" || post.rating === "q") ? Theme.danger : Theme.borderDim
Image {
anchors.fill: parent
anchors.margins: 2
source: "file://" + post.localThumb
fillMode: Image.PreserveAspectCrop
asynchronous: true
clip: true
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
booruCol.selected = post;
booruCol.downloadedPath = "";
booruCol.previewReady = false;
var ext = (post.fileExt || "jpg").toLowerCase();
// Image can't decode video files (largeUrl for a
// video post IS the video itself, not a still
// frame) or, on this Qt build, webp — so skip the
// preview fetch entirely for those rather than
// trying (and failing) to load it.
if (["mp4", "webm", "mov", "avi", "webp"].indexOf(ext) !== -1) {
booruCol.previewLocalPath = "";
return;
}
var previewPath = booruCol.cacheDir + "/preview_" + booruSettings.source + "_" + post.id + "." + ext;
booruCol.previewLocalPath = previewPath;
previewProc.command = ["curl", "-s", post.largeUrl, "-o", previewPath];
previewProc.running = true;
}
}
}
}
}
// -- Pagination --
// Flow, not Row: Row lays out children at their natural
// width regardless of the width assigned to the Row
// itself, so when Prev + "Page" + the page field + "of
// N" + Next added up to more than booruCol.width (which
// happened often enough — "of N" varies in width with
// page count, and it was already close to the edge) the
// Next button rendered past the flyout's right edge,
// off the visible screen. Flow wraps overflow onto a
// second line instead of spilling past the edge.
Flow {
visible: !booruCol.searching && (booruCol.page > 1 || booruCol.hasNextPage)
width: booruCol.width
spacing: 8
Rectangle {
width: 70; height: 26
opacity: booruCol.page > 1 ? 1 : 0.4
color: prevMa.containsMouse ? Theme.bgAlt : "transparent"
border.width: 1
border.color: Theme.border
Text {
anchors.centerIn: parent
text: "< Prev"
color: Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 11
}
MouseArea {
id: prevMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (booruCol.page <= 1) return;
booruCol.page -= 1;
booruCol.fetchPage();
}
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: "Page"
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 11
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 40; height: 26
color: Theme.bgAlt
border.width: 1
border.color: Theme.border
TextInput {
id: pageField
anchors.fill: parent
anchors.margins: 4
horizontalAlignment: TextInput.AlignHCenter
color: Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 11
text: String(booruCol.page)
validator: IntValidator { bottom: 1; top: 999999 }
Keys.onReturnPressed: {
var n = parseInt(pageField.text);
if (isNaN(n) || n < 1) { pageField.text = String(booruCol.page); return; }
booruCol.page = booruCol.totalPages > 0 ? Math.min(n, booruCol.totalPages) : n;
booruCol.fetchPage();
}
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: booruCol.totalPages > 0 ? ("of " + booruCol.totalPages) : ""
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 11
}
Rectangle {
width: 70; height: 26
opacity: booruCol.hasNextPage ? 1 : 0.4
color: nextMa.containsMouse ? Theme.bgAlt : "transparent"
border.width: 1
border.color: Theme.border
Text {
anchors.centerIn: parent
text: "Next >"
color: Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 11
}
MouseArea {
id: nextMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (!booruCol.hasNextPage) return;
booruCol.page += 1;
booruCol.fetchPage();
}
}
}
}
}
}
Component {
id: booruDetail
Column {
width: booruCol.width
spacing: 8
Rectangle {
width: 60
height: 26
color: backMa.containsMouse ? Theme.bgAlt : "transparent"
border.width: 1
border.color: Theme.border
Text {
anchors.centerIn: parent
text: "< Back"
color: Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 10
}
MouseArea {
id: backMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: booruCol.selected = null
}
}
Rectangle {
width: booruCol.width
height: 240
color: "#000000"
border.width: 1
border.color: Theme.borderDim
clip: true
Image {
anchors.fill: parent
source: booruCol.previewReady ? ("file://" + booruCol.previewLocalPath) : ""
fillMode: Image.PreserveAspectFit
asynchronous: true
}
Text {
visible: !booruCol.previewReady && !booruCol.selectedNoPreview
anchors.centerIn: parent
text: "Loading preview..."
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 11
}
Text {
visible: booruCol.selectedNoPreview
anchors.centerIn: parent
text: "Video post — no preview.\nDownload to view it."
horizontalAlignment: Text.AlignHCenter
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 11
}
}
Text {
visible: !!booruCol.selected
text: booruCol.selected
? (booruCol.selected.width + "x" + booruCol.selected.height + " · rating: " + booruCol.selected.rating)
: ""
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 10
}
Text {
visible: !!(booruCol.selected && booruCol.selected.tagString)
text: booruCol.selected ? booruCol.selected.tagString : ""
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 9
wrapMode: Text.WordWrap
width: booruCol.width
maximumLineCount: 3
elide: Text.ElideRight
}
Rectangle {
width: booruCol.width
height: 30
color: dlMa.containsMouse ? Theme.bgAlt : "transparent"
border.width: 2
border.color: Theme.accent
opacity: booruCol.downloading ? 0.6 : 1
Text {
anchors.centerIn: parent
text: booruCol.downloading ? "Downloading..." : (booruCol.downloadedPath !== "" ? "Downloaded" : "Download")
color: Theme.accent
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 12
font.bold: true
}
MouseArea {
id: dlMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (booruCol.downloading || booruCol.downloadedPath !== "" || !booruCol.selected) return;
var post = booruCol.selected;
var ext = post.fileExt || "jpg";
var target = booruCol.wallpapersDir + "/" + booruSettings.source + "_" + post.id + "." + ext;
booruCol.downloadTarget = target;
downloadProc.command = ["curl", "-sL", post.fileUrl, "-o", target];
booruCol.downloading = true;
downloadProc.running = true;
}
}
}
Rectangle {
visible: booruCol.downloadedPath !== ""
width: booruCol.width
height: 30
color: setMa.containsMouse ? Theme.bgAlt : "transparent"
border.width: 1
border.color: Theme.border
Text {
anchors.centerIn: parent
text: "Set as Wallpaper"
color: Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 12
}
MouseArea {
id: setMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
setWpProc.command = ["wallpaper-set", booruCol.downloadedPath];
setWpProc.running = true;
}
}
}
}
}
}
}
// --- Cursor -------------------------------------------------------
Component {
id: cursorPanel
Column {
id: cursorCol
spacing: 6
width: loader.width
property var themes: []
property bool previewsReady: false
// Xcursor files are a binary format, not something Image can
// load directly — cursor-preview-all renders a PNG per theme
// into this cache dir (skipping ones already rendered).
property string previewDir: Quickshell.cacheDir + "/cursors"
Process {
id: listThemesProc
command: ["list-cursor-themes"]
stdout: StdioCollector {
onStreamFinished: cursorCol.themes = text.split("\n").filter((s) => s.length > 0)
}
}
Process {
id: previewGenProc
command: ["cursor-preview-all", cursorCol.previewDir]
onExited: cursorCol.previewsReady = true
}
Component.onCompleted: {
listThemesProc.running = true;
previewGenProc.running = true;
}
Process { id: applyProc; command: [] }
Text {
text: "Click a theme to apply"
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 10
}
Text {
visible: cursorCol.themes.length === 0
text: "No cursor themes found"
color: Theme.fgDim
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 12
}
Repeater {
model: cursorCol.themes
delegate: Rectangle {
id: curTile
property string themeName: modelData
property string previewSrc: cursorCol.previewsReady
? "file://" + cursorCol.previewDir + "/" + themeName + ".png" : ""
width: loader.width
height: 36
color: curMa.containsMouse ? Theme.bgAlt : "transparent"
border.width: 1
border.color: Theme.borderDim
IconImage {
id: curIcon
visible: curTile.previewSrc !== "" && status === Image.Ready
implicitSize: 24
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
source: curTile.previewSrc
}
Text {
anchors.left: curIcon.visible ? curIcon.right : parent.left
anchors.leftMargin: curIcon.visible ? 8 : 10
anchors.verticalCenter: parent.verticalCenter
text: curTile.themeName
color: Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 12
}
MouseArea {
id: curMa
anchors.fill: parent
hoverEnabled: true
onClicked: { applyProc.command = ["cursor-set", curTile.themeName]; applyProc.running = true; }
}
}
}
}
}
}