mirror of
https://github.com/Nexoniarz/nexoniarz-config-nixos.git
synced 2026-11-03 04:24:27 +00:00
Display Settings: drag-to-arrange multi-monitor canvas with edge snapping (locked when only one screen is connected), 8/10-bit depth, color management presets (sRGB/Wide/EDID/HDR+EDID), SDR brightness/saturation, ICC profile path, VRR toggle. display-set now takes a single pre-built descriptor instead of a fixed positional arg list, since Hyprland's monitor keyword grew well past that. Wallpapers: fixed the current-wallpaper highlight never loading on open, added real first-frame thumbnails for animated wallpapers (wallpaper-thumbs + ffmpeg), bigger 2-column grid, filename filter. Booru: separate downloads folder from Wallpapers so browsing doesn't clutter the wallpaper picker, bigger grid, Safe/Moderate/Explicit rating tiers replacing the old binary toggle, sort order (Newest/ Score/Random). Fixed pagination being completely broken (a Flow anchor violation was silently disabling the whole layout) and Danbooru's Random sort (order:random tag times out server-side -- switched to their random=true endpoint for that case). Wi-Fi panel: added a "Scanning.../No networks found" state and a manual rescan button -- previously showed nothing either way, making "still scanning" indistinguishable from a real failure. Bluetooth: auto-scans on open like Wi-Fi now, instead of requiring a manual click every time. New: System Monitor panel (CPU/RAM/GPU/VRAM/disk usage, NVIDIA or AMD detected automatically) and Weather panel (IP-geolocated or a typed city, current + hourly forecast, Open-Meteo, no API key) with a compact taskbar badge.
2618 lines
126 KiB
QML
2618 lines
126 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/color 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. display-set (modules/scripts/display-set.sh) then
|
|
// also persists the exact same descriptor into display.conf so it
|
|
// survives a Hyprland restart.
|
|
Component {
|
|
id: settingsPanel
|
|
Column {
|
|
id: dispRoot
|
|
width: loader.width
|
|
spacing: 16
|
|
|
|
// Hyprland's `monitor` keyword takes the FULL descriptor every
|
|
// time — there's no way to change just one field. hyprctl's own
|
|
// readback (Hyprland.monitors / lastIpcObject) covers most of
|
|
// that (mode, scale, transform, bitdepth via currentFormat, cm,
|
|
// sdrBrightness/Saturation, vrr) but NOT icc (never reported
|
|
// back anywhere in `hyprctl monitors -j`). So this tracks
|
|
// whatever WE last applied per monitor, keyed by name, and
|
|
// every apply (including a plain drag-to-reposition on the
|
|
// canvas below) resends the last-known-good value for every
|
|
// field, live-readback for anything never customized —
|
|
// otherwise repositioning a monitor with a custom ICC profile
|
|
// would silently drop that profile on the next apply.
|
|
property var overrides: ({})
|
|
function ovr(name) { return dispRoot.overrides[name] || ({}); }
|
|
function setOvr(name, patch) {
|
|
var updated = {};
|
|
for (var k in dispRoot.overrides) updated[k] = dispRoot.overrides[k];
|
|
var merged = {};
|
|
var cur = updated[name] || ({});
|
|
for (var k2 in cur) merged[k2] = cur[k2];
|
|
for (var k3 in patch) merged[k3] = patch[k3];
|
|
updated[name] = merged;
|
|
dispRoot.overrides = updated;
|
|
}
|
|
function posFor(name, lx, ly) {
|
|
var o = dispRoot.overrides[name];
|
|
return (o && o.x !== undefined) ? { x: o.x, y: o.y } : { x: lx, y: ly };
|
|
}
|
|
|
|
Text {
|
|
visible: Hyprland.monitors.values.length === 0
|
|
text: "No monitor data"
|
|
color: Theme.fgDim
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 12
|
|
}
|
|
|
|
// -- Arrangement canvas --------------------------------------
|
|
// Drag-to-position, like every other DE's display panel — the
|
|
// only way to set position at all before this (previously the
|
|
// per-monitor Apply always resent the monitor's own current
|
|
// x/y unchanged, so multi-monitor layout couldn't be edited
|
|
// from this panel).
|
|
Column {
|
|
id: arrColumn
|
|
visible: Hyprland.monitors.values.length > 0
|
|
width: parent.width
|
|
spacing: 6
|
|
|
|
Text {
|
|
text: "Arrangement"
|
|
color: Theme.fg
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 13
|
|
font.bold: true
|
|
}
|
|
Text {
|
|
text: "Drag to position screens — edges snap together."
|
|
color: Theme.fgDim
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 9
|
|
}
|
|
|
|
// Logical (post-scale) bounding box of every monitor's
|
|
// current-or-pending position, fit into the canvas — this
|
|
// is what Hyprland actually tiles monitors in, not raw
|
|
// pixel mode size.
|
|
property real cScale: {
|
|
var maxX = 1, maxY = 1;
|
|
for (var i = 0; i < Hyprland.monitors.values.length; i++) {
|
|
var mm = Hyprland.monitors.values[i];
|
|
var p = dispRoot.posFor(mm.name, mm.x, mm.y);
|
|
maxX = Math.max(maxX, p.x + mm.width / mm.scale);
|
|
maxY = Math.max(maxY, p.y + mm.height / mm.scale);
|
|
}
|
|
return Math.min((loader.width - 4) / maxX, 130 / maxY);
|
|
}
|
|
|
|
Item {
|
|
id: arrCanvas
|
|
width: loader.width
|
|
height: 140
|
|
|
|
Repeater {
|
|
model: Hyprland.monitors
|
|
delegate: Rectangle {
|
|
id: monRect
|
|
property var m: modelData
|
|
property real lw: m.width / m.scale
|
|
property real lh: m.height / m.scale
|
|
property var pos: dispRoot.posFor(m.name, m.x, m.y)
|
|
property bool dragging: false
|
|
property real baseX: pos.x * arrColumn.cScale
|
|
property real baseY: pos.y * arrColumn.cScale
|
|
property real offX: 0
|
|
property real offY: 0
|
|
width: Math.max(28, lw * arrColumn.cScale)
|
|
height: Math.max(28, lh * arrColumn.cScale)
|
|
x: baseX + (dragging ? offX : 0)
|
|
y: baseY + (dragging ? offY : 0)
|
|
color: dragging ? Theme.accent : Theme.bgAlt
|
|
border.width: 2
|
|
border.color: m.focused ? Theme.accent : Theme.border
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: monRect.m.name
|
|
color: monRect.dragging ? Theme.bg : Theme.fg
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 10
|
|
}
|
|
|
|
Process { id: moveProc }
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
// Nothing to arrange a single screen relative
|
|
// to — disabled outright rather than just
|
|
// no-op-ing onReleased, so it doesn't even
|
|
// hover/cursor-hint as draggable.
|
|
enabled: Hyprland.monitors.values.length > 1
|
|
hoverEnabled: true
|
|
cursorShape: Qt.SizeAllCursor
|
|
property real pressX: 0
|
|
property real pressY: 0
|
|
onPressed: (mouse) => {
|
|
monRect.dragging = true;
|
|
pressX = mouse.x;
|
|
pressY = mouse.y;
|
|
}
|
|
onPositionChanged: (mouse) => {
|
|
if (!monRect.dragging) return;
|
|
monRect.offX += (mouse.x - pressX);
|
|
monRect.offY += (mouse.y - pressY);
|
|
}
|
|
onReleased: {
|
|
var scale = arrColumn.cScale;
|
|
var rawX = (monRect.baseX + monRect.offX) / scale;
|
|
var rawY = (monRect.baseY + monRect.offY) / scale;
|
|
|
|
// Snap to any other monitor's edges within a
|
|
// small threshold — hand-aligning pixel-exact
|
|
// is otherwise a losing game against a
|
|
// ~300px canvas representing a desktop
|
|
// that's thousands of pixels wide.
|
|
var snapPx = 10 / scale;
|
|
var bestX = rawX, bestY = rawY, bestDX = snapPx, bestDY = snapPx;
|
|
for (var i = 0; i < Hyprland.monitors.values.length; i++) {
|
|
var other = Hyprland.monitors.values[i];
|
|
if (other.name === monRect.m.name) continue;
|
|
var op = dispRoot.posFor(other.name, other.x, other.y);
|
|
var ow = other.width / other.scale, oh = other.height / other.scale;
|
|
var xs = [op.x - monRect.lw, op.x, op.x + ow - monRect.lw, op.x + ow];
|
|
var ys = [op.y - monRect.lh, op.y, op.y + oh - monRect.lh, op.y + oh];
|
|
for (var xi = 0; xi < xs.length; xi++) {
|
|
var dx = Math.abs(xs[xi] - rawX);
|
|
if (dx < bestDX) { bestDX = dx; bestX = xs[xi]; }
|
|
}
|
|
for (var yi = 0; yi < ys.length; yi++) {
|
|
var dy = Math.abs(ys[yi] - rawY);
|
|
if (dy < bestDY) { bestDY = dy; bestY = ys[yi]; }
|
|
}
|
|
}
|
|
|
|
var finalX = Math.round(bestX);
|
|
var finalY = Math.round(bestY);
|
|
monRect.dragging = false;
|
|
monRect.offX = 0;
|
|
monRect.offY = 0;
|
|
|
|
var ov = dispRoot.ovr(monRect.m.name);
|
|
var ipc = monRect.m.lastIpcObject || ({});
|
|
var bitdepth = ov.bitdepth !== undefined ? ov.bitdepth :
|
|
((ipc.currentFormat && ipc.currentFormat.indexOf("2101010") !== -1) ? "10" : "8");
|
|
var cm = ov.cm !== undefined ? ov.cm : (ipc.colorManagementPreset || "srgb");
|
|
var sdrB = ov.sdrBrightness !== undefined ? ov.sdrBrightness : (ipc.sdrBrightness !== undefined ? ipc.sdrBrightness : 1.0);
|
|
var sdrS = ov.sdrSaturation !== undefined ? ov.sdrSaturation : (ipc.sdrSaturation !== undefined ? ipc.sdrSaturation : 1.0);
|
|
var vrr = ov.vrr !== undefined ? ov.vrr : !!ipc.vrr;
|
|
var icc = ov.icc !== undefined ? ov.icc : "";
|
|
var mode = monRect.m.width + "x" + monRect.m.height + "@" + (ipc.refreshRate || 60).toFixed(2) + "Hz";
|
|
var tail = mode + "," + finalX + "x" + finalY + "," + monRect.m.scale.toFixed(2)
|
|
+ ",transform," + (ipc.transform || 0)
|
|
+ ",bitdepth," + bitdepth
|
|
+ ",cm," + cm
|
|
+ ",sdrbrightness," + sdrB.toFixed(2)
|
|
+ ",sdrsaturation," + sdrS.toFixed(2)
|
|
+ ",vrr," + (vrr ? "1" : "0")
|
|
+ (icc.length > 0 ? ",icc," + icc : "");
|
|
|
|
dispRoot.setOvr(monRect.m.name, {
|
|
x: finalX, y: finalY, bitdepth: bitdepth, cm: cm,
|
|
sdrBrightness: sdrB, sdrSaturation: sdrS, vrr: vrr, icc: icc
|
|
});
|
|
moveProc.command = ["display-set", monRect.m.name, tail];
|
|
moveProc.running = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle { width: parent.width; height: 1; color: Theme.borderDim }
|
|
|
|
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 || []
|
|
property var ov: dispRoot.ovr(mon.name)
|
|
// 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
|
|
|
|
// Bit depth Hyprland actually requests from the GPU for
|
|
// this output — 8 or 10 only. Not the monitor panel's
|
|
// real physical depth (plenty of "8-bit" panels are
|
|
// 6-bit+FRC underneath): Hyprland has no way to query or
|
|
// change that, it's fixed in the panel's own hardware.
|
|
property string selectedBitdepth: monDelegate.ov.bitdepth !== undefined ? monDelegate.ov.bitdepth :
|
|
((ipc.currentFormat && ipc.currentFormat.indexOf("2101010") !== -1) ? "10" : "8")
|
|
property string selectedCm: monDelegate.ov.cm !== undefined ? monDelegate.ov.cm : (ipc.colorManagementPreset || "srgb")
|
|
property real selectedSdrBrightness: monDelegate.ov.sdrBrightness !== undefined ? monDelegate.ov.sdrBrightness :
|
|
(ipc.sdrBrightness !== undefined ? ipc.sdrBrightness : 1.0)
|
|
property real selectedSdrSaturation: monDelegate.ov.sdrSaturation !== undefined ? monDelegate.ov.sdrSaturation :
|
|
(ipc.sdrSaturation !== undefined ? ipc.sdrSaturation : 1.0)
|
|
property bool selectedVrr: monDelegate.ov.vrr !== undefined ? monDelegate.ov.vrr : !!ipc.vrr
|
|
// Never read back from hyprctl (see dispRoot.overrides
|
|
// comment above) — blank here just means "nothing typed
|
|
// this session", not "no profile is active".
|
|
property string selectedIcc: monDelegate.ov.icc !== undefined ? monDelegate.ov.icc : ""
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -- Bit depth --
|
|
Text {
|
|
text: "Bit depth"
|
|
color: Theme.fgDim
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 9
|
|
font.bold: true
|
|
}
|
|
Row {
|
|
spacing: 6
|
|
Repeater {
|
|
model: ["8", "10"]
|
|
delegate: Rectangle {
|
|
property string bd: modelData
|
|
width: 64; height: 26
|
|
color: monDelegate.selectedBitdepth === bd ? Theme.accent : (bdMa.containsMouse ? Theme.bgAlt : "transparent")
|
|
border.width: 1
|
|
border.color: monDelegate.selectedBitdepth === bd ? Theme.accent : Theme.border
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: bd + "-bit"
|
|
color: monDelegate.selectedBitdepth === bd ? Theme.bg : Theme.fg
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 11
|
|
}
|
|
MouseArea {
|
|
id: bdMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: monDelegate.selectedBitdepth = bd
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -- Color management preset --
|
|
Text {
|
|
text: "Color profile"
|
|
color: Theme.fgDim
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 9
|
|
font.bold: true
|
|
}
|
|
Row {
|
|
spacing: 6
|
|
Repeater {
|
|
model: [
|
|
{ v: "srgb", label: "sRGB" },
|
|
{ v: "wide", label: "Wide" },
|
|
{ v: "edid", label: "EDID" },
|
|
{ v: "hdredid", label: "HDR" }
|
|
]
|
|
delegate: Rectangle {
|
|
property string cv: modelData.v
|
|
width: (monDelegate.width - 18) / 4; height: 26
|
|
color: monDelegate.selectedCm === cv ? Theme.accent : (cmMa.containsMouse ? Theme.bgAlt : "transparent")
|
|
border.width: 1
|
|
border.color: monDelegate.selectedCm === cv ? Theme.accent : Theme.border
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: modelData.label
|
|
color: monDelegate.selectedCm === cv ? Theme.bg : Theme.fg
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 10
|
|
}
|
|
MouseArea {
|
|
id: cmMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: monDelegate.selectedCm = cv
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -- SDR brightness/saturation -- only meaningful once
|
|
// the panel isn't running straight sRGB (Hyprland warns
|
|
// about exactly this: wide/HDR presets need these to
|
|
// compensate tone-mapping, sRGB doesn't touch them).
|
|
Text {
|
|
visible: monDelegate.selectedCm !== "srgb"
|
|
text: "SDR brightness: " + monDelegate.selectedSdrBrightness.toFixed(2) + "x"
|
|
color: Theme.fgDim
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 9
|
|
font.bold: true
|
|
}
|
|
Row {
|
|
visible: monDelegate.selectedCm !== "srgb"
|
|
spacing: 6
|
|
Rectangle {
|
|
width: 30; height: 26
|
|
color: sdrBDownMa.containsMouse ? Theme.bgAlt : "transparent"
|
|
border.width: 1
|
|
border.color: Theme.border
|
|
Text { anchors.centerIn: parent; text: "-"; color: Theme.fg; font.pixelSize: 14 }
|
|
MouseArea {
|
|
id: sdrBDownMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: monDelegate.selectedSdrBrightness = Math.max(0.1, Math.round((monDelegate.selectedSdrBrightness - 0.05) * 100) / 100)
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: 30; height: 26
|
|
color: sdrBUpMa.containsMouse ? Theme.bgAlt : "transparent"
|
|
border.width: 1
|
|
border.color: Theme.border
|
|
Text { anchors.centerIn: parent; text: "+"; color: Theme.fg; font.pixelSize: 14 }
|
|
MouseArea {
|
|
id: sdrBUpMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: monDelegate.selectedSdrBrightness = Math.min(3.0, Math.round((monDelegate.selectedSdrBrightness + 0.05) * 100) / 100)
|
|
}
|
|
}
|
|
}
|
|
Text {
|
|
visible: monDelegate.selectedCm !== "srgb"
|
|
text: "SDR saturation: " + monDelegate.selectedSdrSaturation.toFixed(2) + "x"
|
|
color: Theme.fgDim
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 9
|
|
font.bold: true
|
|
}
|
|
Row {
|
|
visible: monDelegate.selectedCm !== "srgb"
|
|
spacing: 6
|
|
Rectangle {
|
|
width: 30; height: 26
|
|
color: sdrSDownMa.containsMouse ? Theme.bgAlt : "transparent"
|
|
border.width: 1
|
|
border.color: Theme.border
|
|
Text { anchors.centerIn: parent; text: "-"; color: Theme.fg; font.pixelSize: 14 }
|
|
MouseArea {
|
|
id: sdrSDownMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: monDelegate.selectedSdrSaturation = Math.max(0.1, Math.round((monDelegate.selectedSdrSaturation - 0.05) * 100) / 100)
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: 30; height: 26
|
|
color: sdrSUpMa.containsMouse ? Theme.bgAlt : "transparent"
|
|
border.width: 1
|
|
border.color: Theme.border
|
|
Text { anchors.centerIn: parent; text: "+"; color: Theme.fg; font.pixelSize: 14 }
|
|
MouseArea {
|
|
id: sdrSUpMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: monDelegate.selectedSdrSaturation = Math.min(2.0, Math.round((monDelegate.selectedSdrSaturation + 0.05) * 100) / 100)
|
|
}
|
|
}
|
|
}
|
|
|
|
// -- ICC profile --
|
|
Text {
|
|
text: "ICC profile (optional)"
|
|
color: Theme.fgDim
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 9
|
|
font.bold: true
|
|
}
|
|
Row {
|
|
spacing: 6
|
|
Rectangle {
|
|
width: monDelegate.width - 62
|
|
height: 30
|
|
color: Theme.bgAlt
|
|
border.width: 1
|
|
border.color: Theme.border
|
|
TextInput {
|
|
id: iccInput
|
|
anchors.fill: parent
|
|
anchors.margins: 8
|
|
text: monDelegate.selectedIcc
|
|
onTextEdited: monDelegate.selectedIcc = text
|
|
color: Theme.fg
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 11
|
|
clip: true
|
|
Text {
|
|
visible: iccInput.text.length === 0
|
|
text: "/path/to/profile.icc"
|
|
color: Theme.fgDim
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 11
|
|
}
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: 56; height: 30
|
|
color: clearIccMa.containsMouse ? Theme.bgAlt : "transparent"
|
|
border.width: 1
|
|
border.color: Theme.border
|
|
Text { anchors.centerIn: parent; text: "Clear"; color: Theme.fg; font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 10 }
|
|
MouseArea {
|
|
id: clearIccMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: monDelegate.selectedIcc = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// -- VRR --
|
|
Item {
|
|
width: monDelegate.width
|
|
height: 26
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "Variable refresh rate"
|
|
color: Theme.fg
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 11
|
|
}
|
|
ToggleSwitch {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
checked: monDelegate.selectedVrr
|
|
onToggled: monDelegate.selectedVrr = !monDelegate.selectedVrr
|
|
}
|
|
}
|
|
|
|
// -- 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: {
|
|
var pos = dispRoot.posFor(monDelegate.mon.name, monDelegate.mon.x, monDelegate.mon.y);
|
|
var tail = monDelegate.selectedMode + "," + pos.x + "x" + pos.y + "," + monDelegate.selectedScale.toFixed(2)
|
|
+ ",transform," + monDelegate.selectedTransform
|
|
+ ",bitdepth," + monDelegate.selectedBitdepth
|
|
+ ",cm," + monDelegate.selectedCm
|
|
+ ",sdrbrightness," + monDelegate.selectedSdrBrightness.toFixed(2)
|
|
+ ",sdrsaturation," + monDelegate.selectedSdrSaturation.toFixed(2)
|
|
+ ",vrr," + (monDelegate.selectedVrr ? "1" : "0")
|
|
+ (monDelegate.selectedIcc.length > 0 ? ",icc," + monDelegate.selectedIcc : "");
|
|
|
|
dispRoot.setOvr(monDelegate.mon.name, {
|
|
x: pos.x, y: pos.y, bitdepth: monDelegate.selectedBitdepth, cm: monDelegate.selectedCm,
|
|
sdrBrightness: monDelegate.selectedSdrBrightness, sdrSaturation: monDelegate.selectedSdrSaturation,
|
|
vrr: monDelegate.selectedVrr, icc: monDelegate.selectedIcc
|
|
});
|
|
applyProc.command = ["display-set", monDelegate.mon.name, tail];
|
|
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;
|
|
currentProc.running = true;
|
|
}
|
|
|
|
// Reads back the actually-active wallpaper so the grid can
|
|
// highlight it on open — wpCol.current previously only ever
|
|
// got set by clicking a tile *this session*, so a wallpaper
|
|
// set before Quickshell last restarted (including the normal
|
|
// wallpaper-restore-on-login path) never showed as selected
|
|
// even though it really was active. wallpaper-set's own state
|
|
// file is a real `wallpaper-set <path> ...` command line
|
|
// (shell-quoted via printf %q) — sourcing it with the
|
|
// function shadowed to just echo its first argument is a
|
|
// simple, correct way to pull the path back out without
|
|
// re-implementing shell-quote parsing in JS.
|
|
Process {
|
|
id: currentProc
|
|
command: ["bash", "-c",
|
|
'wallpaper-set() { printf "%s\\n" "$1"; }; source "$1" 2>/dev/null',
|
|
"--", Quickshell.env("HOME") + "/.config/hypr/wallpaper.conf"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
var p = text.trim();
|
|
if (p.length > 0) wpCol.current = p;
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
wpCol.refreshThumbs();
|
|
}
|
|
}
|
|
}
|
|
|
|
// path -> cached preview JPEG, filled in by wallpaper-thumbs
|
|
// (modules/scripts/wallpaper-thumbs.sh) once it's generated a
|
|
// real first-frame thumbnail for each animated file — a tile
|
|
// with no entry here just falls back to the play-icon glyph
|
|
// (thumbnail not generated yet, or ffmpeg couldn't read that
|
|
// file). Re-run any time the animated file list changes, not
|
|
// just once, so newly-added videos get a thumbnail without
|
|
// needing a full panel close/reopen.
|
|
property var thumbMap: ({})
|
|
property string thumbCacheDir: Quickshell.cacheDir + "/wallpaper-thumbs"
|
|
function refreshThumbs() {
|
|
if (wpCol.animatedImages.length === 0) return;
|
|
thumbProc.command = ["wallpaper-thumbs", wpCol.thumbCacheDir].concat(wpCol.animatedImages);
|
|
thumbProc.running = true;
|
|
}
|
|
Process {
|
|
id: thumbProc
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
var map = {};
|
|
var lines = text.split("\n").filter((s) => s.length > 0);
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var parts = lines[i].split("\t");
|
|
if (parts.length === 2) map[parts[0]] = parts[1];
|
|
}
|
|
wpCol.thumbMap = map;
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// -- Search --
|
|
property string filterText: ""
|
|
property var filteredStatic: {
|
|
if (wpCol.filterText.length === 0) return wpCol.staticImages;
|
|
var q = wpCol.filterText.toLowerCase();
|
|
return wpCol.staticImages.filter((p) => p.toLowerCase().indexOf(q) !== -1);
|
|
}
|
|
property var filteredAnimated: {
|
|
if (wpCol.filterText.length === 0) return wpCol.animatedImages;
|
|
var q = wpCol.filterText.toLowerCase();
|
|
return wpCol.animatedImages.filter((p) => p.toLowerCase().indexOf(q) !== -1);
|
|
}
|
|
Rectangle {
|
|
width: wpCol.width
|
|
height: 30
|
|
color: Theme.bgAlt
|
|
border.width: 1
|
|
border.color: Theme.border
|
|
TextInput {
|
|
id: filterInput
|
|
anchors.fill: parent
|
|
anchors.margins: 8
|
|
text: wpCol.filterText
|
|
onTextEdited: wpCol.filterText = text
|
|
color: Theme.fg
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 12
|
|
clip: true
|
|
Text {
|
|
visible: filterInput.text.length === 0
|
|
text: "Filter by filename..."
|
|
color: Theme.fgDim
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 12
|
|
}
|
|
}
|
|
}
|
|
|
|
// -- 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
|
|
}
|
|
Text {
|
|
visible: (wpCol.staticImages.length > 0 || wpCol.animatedImages.length > 0)
|
|
&& wpCol.filteredStatic.length === 0 && wpCol.filteredAnimated.length === 0
|
|
text: "No wallpapers match \"" + wpCol.filterText + "\""
|
|
color: Theme.fgDim
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 12
|
|
}
|
|
|
|
Column {
|
|
visible: wpCol.filteredStatic.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.filteredStatic
|
|
delegate: Rectangle {
|
|
id: staticTile
|
|
property string path: modelData
|
|
// 2 per row rather than the old fixed 92px (3
|
|
// cramped columns) — bigger previews are the
|
|
// whole point of a wallpaper picker.
|
|
width: (wpCol.width - 8) / 2
|
|
height: width
|
|
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.filteredAnimated.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.filteredAnimated
|
|
delegate: Rectangle {
|
|
id: animTile
|
|
property string path: modelData
|
|
property string fileName: path.split("/").pop()
|
|
property string thumb: wpCol.thumbMap[path] || ""
|
|
width: (wpCol.width - 8) / 2
|
|
height: width
|
|
color: Theme.bgAlt
|
|
border.width: wpCol.current === path ? 2 : 1
|
|
border.color: wpCol.current === path ? Theme.accent : Theme.borderDim
|
|
|
|
HoverHandler { id: animHover }
|
|
|
|
// Real first-frame preview once wallpaper-thumbs
|
|
// has generated one (see wpCol.thumbMap above);
|
|
// falls back to a play glyph + filename until
|
|
// then, or if ffmpeg couldn't read that file.
|
|
Image {
|
|
id: animThumb
|
|
anchors.fill: parent
|
|
anchors.margins: 2
|
|
source: animTile.thumb.length > 0 ? ("file://" + animTile.thumb) : ""
|
|
fillMode: Image.PreserveAspectCrop
|
|
asynchronous: true
|
|
clip: true
|
|
visible: status === Image.Ready
|
|
}
|
|
Text {
|
|
visible: !animThumb.visible
|
|
anchors.centerIn: parent
|
|
text: "▶"
|
|
color: Theme.fgDim
|
|
font.pixelSize: 22
|
|
anchors.verticalCenterOffset: -10
|
|
}
|
|
// Small corner badge marking it as a video even
|
|
// once a real thumbnail is showing — otherwise
|
|
// it'd be indistinguishable from a static image.
|
|
Text {
|
|
visible: animThumb.visible
|
|
anchors.top: parent.top
|
|
anchors.right: parent.right
|
|
anchors.margins: 4
|
|
text: "▶"
|
|
color: Theme.fg
|
|
font.pixelSize: 12
|
|
style: Text.Outline
|
|
styleColor: Theme.bg
|
|
}
|
|
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: animThumb.visible ? Theme.fg : Theme.fgDim
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 9
|
|
elide: Text.ElideMiddle
|
|
style: animThumb.visible ? Text.Outline : Text.Normal
|
|
styleColor: Theme.bg
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: wpCol.apply(path)
|
|
}
|
|
|
|
DeleteTileButton {
|
|
visible: animHover.hovered
|
|
onClicked: wpCol.deleteWallpaper(animTile.path)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Booru (Danbooru, e621, Yande.re, Konachan — Gelbooru/Rule34.xxx
|
|
// need a registered account + API key, skipped for that reason) -------
|
|
// Defaults to Safe content rating; the Content rating tiers (Safe/
|
|
// Moderate/Explicit) control how far that's relaxed. Clicking a result
|
|
// opens a bigger preview rather than downloading immediately —
|
|
// Download is an explicit separate action (saves to downloadsDir, not
|
|
// the Wallpapers folder), 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
|
|
// "safe" | "moderate" | "explicit" — moderate excludes only
|
|
// explicit-rated posts (allows questionable through),
|
|
// replacing the old binary lewds on/off which couldn't
|
|
// express that middle ground at all.
|
|
property string rating: "safe"
|
|
property string source: "danbooru"
|
|
// "newest" (site default/no tag) | "score" | "random"
|
|
property string sortOrder: "newest"
|
|
property string stateFile: Quickshell.stateDir + "/booru-settings.json"
|
|
|
|
function save() {
|
|
var json = JSON.stringify({
|
|
rating: booruSettings.rating, source: booruSettings.source,
|
|
sortOrder: booruSettings.sortOrder
|
|
});
|
|
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);
|
|
// Migrate the old binary lewds flag for
|
|
// anyone whose state file predates the
|
|
// rating tiers — 1 mapped to "no filter at
|
|
// all" (explicit), 0 to the old default
|
|
// (safe), same behavior as before just
|
|
// expressed in the new setting.
|
|
if (d.rating) booruSettings.rating = d.rating;
|
|
else if (d.lewds !== undefined) booruSettings.rating = d.lewds === "1" ? "explicit" : "safe";
|
|
if (d.source) booruSettings.source = d.source;
|
|
if (d.sortOrder) booruSettings.sortOrder = d.sortOrder;
|
|
// This load is async (cat running as a
|
|
// subprocess) while booruCol.doSearch() in
|
|
// Component.onCompleted below runs
|
|
// synchronously right on startup — that first
|
|
// search always fires before this file finishes
|
|
// loading, using the hardcoded defaults
|
|
// (Danbooru/Safe/Newest) rather than whatever
|
|
// was actually saved. Re-run it now that the
|
|
// real settings are in, or the panel opens
|
|
// showing the wrong source/rating/sort with no
|
|
// indication anything's off — as seen live: a
|
|
// "of 548508" Danbooru page count left over
|
|
// and displayed under a Konachan-labeled grid.
|
|
booruCol.doSearch();
|
|
} catch (e) { /* no state file yet — defaults stand */ }
|
|
}
|
|
}
|
|
}
|
|
Component.onCompleted: booruLoadProc.running = true
|
|
}
|
|
|
|
// -- 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.
|
|
//
|
|
// ratingTags: verified live against each API — Danbooru and
|
|
// Yande.re/Konachan (moebooru) write rating tags as full words
|
|
// (rating:general/explicit), e621 uses single-letter codes
|
|
// (rating:s/e) even though the query GRAMMAR is otherwise the
|
|
// same "order:"/"-tag" syntax across all four (also verified
|
|
// live: `tags=order:score` and `tags=-rating:e` both work
|
|
// identically on Danbooru, e621, and moebooru sites).
|
|
readonly property var sources: ({
|
|
danbooru: {
|
|
label: "Danbooru", hasCount: true, hasAutocomplete: true,
|
|
ratingTags: { safe: "rating:general", moderate: "-rating:explicit", explicit: "" }
|
|
},
|
|
e621: {
|
|
label: "e621", hasCount: false, hasAutocomplete: false,
|
|
ratingTags: { safe: "rating:s", moderate: "-rating:e", explicit: "" }
|
|
},
|
|
yandere: {
|
|
label: "Yande.re", hasCount: false, hasAutocomplete: false,
|
|
ratingTags: { safe: "rating:safe", moderate: "-rating:explicit", explicit: "" }
|
|
},
|
|
konachan: {
|
|
label: "Konachan", hasCount: false, hasAutocomplete: false,
|
|
ratingTags: { safe: "rating:safe", moderate: "-rating:explicit", explicit: "" }
|
|
}
|
|
})
|
|
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
|
|
// Downloads land here, NOT directly in Wallpapers — previously
|
|
// every single Download (even ones never actually set as
|
|
// wallpaper) wrote straight into ~/Wallpapers, silently
|
|
// cluttering that picker's grid. "Set as Wallpaper" now copies
|
|
// from here into wallpapersDir explicitly, so only images the
|
|
// user actually chose show up there.
|
|
property string downloadsDir: Quickshell.env("HOME") + "/Booru"
|
|
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).
|
|
// 2 columns (was 3, at a cramped fixed 92px) — bigger tiles,
|
|
// sized off the panel's real width instead of a hardcoded pixel
|
|
// value.
|
|
readonly property int columns: 2
|
|
readonly property real tileSize: (booruCol.width - 8) / 2
|
|
readonly property real tileSlot: booruCol.tileSize + 8
|
|
property int visibleRows: {
|
|
var screenH = (root.screen && root.screen.height) ? root.screen.height : 1080;
|
|
// Reserve space for the taskbar, the flyout header, the
|
|
// search/rating/sort/source rows above the grid, and
|
|
// margins — bumped from 340 when the old single-row Lewds
|
|
// toggle became two full rating+sort rows with their own
|
|
// labels.
|
|
var reserved = 400;
|
|
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)
|
|
: [];
|
|
var ratingTag = booruCol.sources[booruSettings.source].ratingTags[booruSettings.rating];
|
|
if (ratingTag) tagParts.push(ratingTag);
|
|
// order: is a tag, not a URL param, on all four sources
|
|
// (verified live — a plain ?order= query param is silently
|
|
// ignored on the moebooru sites). Danbooru is the one
|
|
// exception: `order:random` as a TAG reliably times out
|
|
// server-side even scoped to a single rating tag (verified
|
|
// live — "ActiveRecord::QueryCanceled, database timed
|
|
// out"), which read as "Random doesn't work, only Score
|
|
// does" from the UI (empty results). Danbooru has a
|
|
// separate, fast `random=true` URL param specifically to
|
|
// avoid this — handled in fetchPage() instead, so it's
|
|
// deliberately skipped here for danbooru.
|
|
if (booruSettings.sortOrder === "score") tagParts.push("order:score");
|
|
else if (booruSettings.sortOrder === "random" && booruSettings.source !== "danbooru") tagParts.push("order:random");
|
|
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;
|
|
// Fast path around the order:random timeout (see
|
|
// buildTagsParam) — Danbooru's own random=true redirects
|
|
// to a URL with real random ordering baked in, which is
|
|
// cheap; page doesn't really apply to "a random
|
|
// sample," so it's left out for this one request.
|
|
if (booruSettings.sortOrder === "random") url = "https://danbooru.donmai.us/posts.json?limit=" + booruCol.pageSize + "&random=true" + 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;
|
|
// -L: danbooru's random=true responds with a redirect —
|
|
// without following it, that request comes back as an
|
|
// empty body (verified live).
|
|
searchProc.command = ["curl", "-s", "-L", "-A", booruCol.userAgent, url];
|
|
booruCol.searching = true;
|
|
booruCol.results = [];
|
|
searchProc.running = true;
|
|
}
|
|
|
|
Process { id: mkdirProc; command: ["mkdir", "-p", booruCol.cacheDir, booruCol.downloadsDir] }
|
|
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;
|
|
}
|
|
}
|
|
|
|
property bool wallpaperSetDone: false
|
|
Process {
|
|
id: setWpProc
|
|
command: []
|
|
onExited: (code) => { if (code === 0) booruCol.wallpaperSetDone = true; }
|
|
}
|
|
|
|
// -- 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Replaces the old binary Lewds on/off — "moderate" lets
|
|
// questionable-rated posts through while still excluding
|
|
// explicit, a middle ground the old toggle couldn't express.
|
|
Text {
|
|
text: "Content rating"
|
|
color: Theme.fgDim
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 9
|
|
font.bold: true
|
|
}
|
|
Row {
|
|
spacing: 6
|
|
Repeater {
|
|
model: [
|
|
{ v: "safe", label: "Safe" },
|
|
{ v: "moderate", label: "Moderate" },
|
|
{ v: "explicit", label: "Explicit" }
|
|
]
|
|
delegate: Rectangle {
|
|
property string rv: modelData.v
|
|
readonly property bool active: booruSettings.rating === rv
|
|
width: (booruCol.width - 12) / 3; height: 26
|
|
color: active ? (rv === "explicit" ? Theme.danger : Theme.accent) : (ratingMa.containsMouse ? Theme.bgAlt : "transparent")
|
|
border.width: 1
|
|
border.color: active ? (rv === "explicit" ? Theme.danger : Theme.accent) : Theme.border
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: modelData.label
|
|
color: active ? Theme.bg : Theme.fg
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 10
|
|
font.bold: true
|
|
}
|
|
MouseArea {
|
|
id: ratingMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
if (booruSettings.rating === rv) return;
|
|
// save() here, not just the property
|
|
// assignment: this rating setting not
|
|
// sticking on its own (only saving as a
|
|
// side effect of also switching source
|
|
// afterward) was a real persistence bug on
|
|
// the old Lewds toggle — keep saving
|
|
// explicitly so it can't regress the same way.
|
|
booruSettings.rating = rv;
|
|
booruSettings.save();
|
|
booruCol.doSearch();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: "Sort"
|
|
color: Theme.fgDim
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 9
|
|
font.bold: true
|
|
}
|
|
Row {
|
|
spacing: 6
|
|
Repeater {
|
|
model: [
|
|
{ v: "newest", label: "Newest" },
|
|
{ v: "score", label: "Score" },
|
|
{ v: "random", label: "Random" }
|
|
]
|
|
delegate: Rectangle {
|
|
property string sv: modelData.v
|
|
readonly property bool active: booruSettings.sortOrder === sv
|
|
width: (booruCol.width - 12) / 3; height: 26
|
|
color: active ? Theme.accent : (sortMa.containsMouse ? Theme.bgAlt : "transparent")
|
|
border.width: 1
|
|
border.color: active ? Theme.accent : Theme.border
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: modelData.label
|
|
color: active ? Theme.bg : Theme.fg
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 10
|
|
}
|
|
MouseArea {
|
|
id: sortMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
if (booruSettings.sortOrder === sv) return;
|
|
booruSettings.sortOrder = sv;
|
|
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: booruCol.tileSize
|
|
height: booruCol.tileSize
|
|
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.wallpaperSetDone = false;
|
|
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 {
|
|
// Danbooru's random=true (see fetchPage) is a fresh
|
|
// random sample every request, not a real page
|
|
// sequence — no &page= param is even sent for it, so
|
|
// "Page N"/Next wouldn't actually mean anything there.
|
|
readonly property bool isDanbooruRandom: booruSettings.source === "danbooru" && booruSettings.sortOrder === "random"
|
|
visible: !booruCol.searching && !isDanbooruRandom && (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();
|
|
}
|
|
}
|
|
}
|
|
// Flow does NOT support anchors on its direct children —
|
|
// Qt silently disables the ENTIRE Flow's layout the
|
|
// moment it sees one ("Flow will not function", logged
|
|
// as a warning), collapsing/overlapping every control
|
|
// in it. This is why pagination looked broken/missing:
|
|
// the three anchored items below were direct Flow
|
|
// children. Fix: push the anchor one level down into a
|
|
// plain Item wrapper, which Flow lays out normally.
|
|
Item {
|
|
width: pageLabelText.implicitWidth
|
|
height: 26
|
|
Text {
|
|
id: pageLabelText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "Page"
|
|
color: Theme.fgDim
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 11
|
|
}
|
|
}
|
|
Rectangle {
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
Item {
|
|
visible: booruCol.totalPages > 0
|
|
width: ofPagesText.implicitWidth
|
|
height: 26
|
|
Text {
|
|
id: ofPagesText
|
|
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.downloadsDir + "/" + 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: booruCol.wallpaperSetDone ? "Wallpaper set!" : "Set as Wallpaper"
|
|
color: booruCol.wallpaperSetDone ? Theme.accent : Theme.fg
|
|
font.family: "JetBrainsMono Nerd Font"
|
|
font.pixelSize: 12
|
|
}
|
|
MouseArea {
|
|
id: setMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
if (!booruCol.selected) return;
|
|
var post = booruCol.selected;
|
|
var ext = post.fileExt || "jpg";
|
|
// Downloaded files live in downloadsDir now, not
|
|
// wallpapersDir directly (see downloadsDir
|
|
// comment above) — copy into wallpapersDir only
|
|
// on this explicit action, then apply it.
|
|
var wpTarget = booruCol.wallpapersDir + "/" + booruSettings.source + "_" + post.id + "." + ext;
|
|
setWpProc.command = ["sh", "-c",
|
|
'cp "$1" "$2" && wallpaper-set "$2"',
|
|
"--", booruCol.downloadedPath, wpTarget];
|
|
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; }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|