diff --git a/dotfiles/quickshell/BottomBar.qml b/dotfiles/quickshell/BottomBar.qml index 3016ec5..144d200 100644 --- a/dotfiles/quickshell/BottomBar.qml +++ b/dotfiles/quickshell/BottomBar.qml @@ -1,6 +1,7 @@ import QtQuick import Quickshell import Quickshell.Hyprland +import Quickshell.Io import Quickshell.Wayland import Quickshell.Widgets @@ -168,6 +169,100 @@ PanelWindow { color: Theme.border } + // --- Weather badge --------------------------------------------------- + // Independent of RightFlyout's own weatherPanel (which only exists + // while the Loader has it active) β€” this needs to keep showing a + // temperature even when the panel's closed, so it fetches on its own + // timer rather than reading state from a panel instance that mostly + // doesn't exist. Reads the same city override (if any) so the badge + // and the full panel never disagree about which location they're + // showing. + Item { + id: wxBadgeState + property var data: ({}) + property bool haveData: !!data.current + property string city: "" + + function describeIcon(code) { + if (code === 0) return "β˜€"; + if (code === 1) return "🌀"; + if (code === 2) return "β›…"; + if (code === 3) return "☁"; + if (code === 45 || code === 48) return "🌫"; + if (code >= 51 && code <= 57) return "🌦"; + if (code >= 61 && code <= 67) return "🌧"; + if (code >= 71 && code <= 77) return "❄"; + if (code >= 80 && code <= 82) return "🌧"; + if (code === 85 || code === 86) return "🌨"; + if (code >= 95) return "β›ˆ"; + return "?"; + } + + Process { + id: wxBadgeCityLoadProc + command: ["cat", Quickshell.stateDir + "/weather-settings.json"] + stdout: StdioCollector { + onStreamFinished: { + try { + var d = JSON.parse(text); + if (d.city) wxBadgeState.city = d.city; + } catch (e) { /* no override saved β€” auto-detect stands */ } + wxBadgeFetchProc.command = wxBadgeState.city.length > 0 + ? ["weather-fetch", wxBadgeState.city] : ["weather-fetch"]; + wxBadgeFetchProc.running = true; + } + } + } + Process { + id: wxBadgeFetchProc + stdout: StdioCollector { + onStreamFinished: { + try { wxBadgeState.data = JSON.parse(text); } catch (e) { /* keep last good reading */ } + } + } + } + Timer { + interval: 900000 + running: true + repeat: true + onTriggered: wxBadgeCityLoadProc.running = true + } + Component.onCompleted: wxBadgeCityLoadProc.running = true + } + + Rectangle { + id: weatherBadge + visible: wxBadgeState.haveData + anchors.right: rightToggle.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + width: wxBadgeLabel.implicitWidth + 16 + height: 32 + color: wxBadgeMa.containsMouse ? Theme.bgAlt : "transparent" + border.width: 1 + border.color: Theme.border + Text { + id: wxBadgeLabel + anchors.centerIn: parent + text: wxBadgeState.haveData + ? (wxBadgeState.describeIcon(wxBadgeState.data.current.weather_code) + " " + Math.round(wxBadgeState.data.current.temperature_2m) + "Β°C") + : "" + color: Theme.fg + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 12 + } + MouseArea { + id: wxBadgeMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.rightBar.expanded = true; + root.rightBar.toggle("weather"); + } + } + } + // --- Right bar toggle --------------------------------------------- Rectangle { id: rightToggle @@ -202,7 +297,7 @@ PanelWindow { Row { anchors.left: parent.left anchors.leftMargin: 8 + 32 + 8 + (root.workspacePills.length * 36) + 16 - anchors.right: rightToggle.left + anchors.right: weatherBadge.left anchors.rightMargin: 8 anchors.verticalCenter: parent.verticalCenter spacing: 4 diff --git a/dotfiles/quickshell/LeftFlyout.qml b/dotfiles/quickshell/LeftFlyout.qml index 7728368..87828ea 100644 --- a/dotfiles/quickshell/LeftFlyout.qml +++ b/dotfiles/quickshell/LeftFlyout.qml @@ -189,16 +189,49 @@ Rectangle { } // --- 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. + // 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" @@ -207,6 +240,173 @@ Rectangle { 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 { @@ -216,6 +416,7 @@ Rectangle { 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 @@ -242,6 +443,24 @@ Rectangle { 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 { @@ -417,6 +636,232 @@ Rectangle { } } + // -- 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 @@ -438,9 +883,22 @@ Rectangle { 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)]; + 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; } } @@ -515,6 +973,31 @@ Rectangle { 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 ...` 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) { @@ -552,9 +1035,41 @@ Rectangle { 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: [] } @@ -569,6 +1084,44 @@ Rectangle { 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 @@ -749,9 +1302,17 @@ Rectangle { 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.staticImages.length > 0 + visible: wpCol.filteredStatic.length > 0 width: wpCol.width spacing: 8 @@ -768,12 +1329,15 @@ Rectangle { spacing: 8 Repeater { - model: wpCol.staticImages + model: wpCol.filteredStatic delegate: Rectangle { id: staticTile property string path: modelData - width: 92 - height: 92 + // 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 @@ -805,7 +1369,7 @@ Rectangle { } Column { - visible: wpCol.animatedImages.length > 0 + visible: wpCol.filteredAnimated.length > 0 width: wpCol.width spacing: 8 @@ -822,29 +1386,56 @@ Rectangle { spacing: 8 Repeater { - model: wpCol.animatedImages + model: wpCol.filteredAnimated delegate: Rectangle { id: animTile property string path: modelData property string fileName: path.split("/").pop() - width: 92 - height: 92 + 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 } - // No live thumbnail for video files β€” a play - // glyph + filename instead of the expense/ - // complexity of extracting a preview frame. + // 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 @@ -854,10 +1445,12 @@ Rectangle { anchors.rightMargin: 4 horizontalAlignment: Text.AlignHCenter text: fileName - color: Theme.fgDim + 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 { @@ -877,13 +1470,14 @@ Rectangle { } } - // --- 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. + // --- 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 { @@ -897,12 +1491,21 @@ Rectangle { // its own small state file instead, same as wpSettings above. Item { id: booruSettings - property string lewds: "0" + // "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({ lewds: booruSettings.lewds, source: booruSettings.source }); + 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]; @@ -917,15 +1520,36 @@ Rectangle { onStreamFinished: { try { var d = JSON.parse(text); - if (d.lewds) booruSettings.lewds = d.lewds; + // 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 } - readonly property bool lewds: booruSettings.lewds === "1" // -- Multi-source support -- // Gelbooru and Rule34.xxx both now require a registered @@ -933,11 +1557,31 @@ Rectangle { // 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", 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 } + 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)" @@ -1019,8 +1663,13 @@ Rectangle { // 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. + // 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 @@ -1033,13 +1682,20 @@ Rectangle { // 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 + // 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/lewds/source rows above the grid, and margins. - var reserved = 340; + // 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 @@ -1052,7 +1708,21 @@ Rectangle { var tagParts = booruCol.query.trim().length > 0 ? booruCol.query.trim().split(/\s+/).map(encodeURIComponent) : []; - if (!booruCol.lewds) tagParts.push(booruCol.sources[booruSettings.source].safeTag); + 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("+"); } @@ -1132,6 +1802,12 @@ Rectangle { 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; @@ -1144,13 +1820,16 @@ Rectangle { break; } searchProc.requestedSource = booruSettings.source; - searchProc.command = ["curl", "-s", "-A", booruCol.userAgent, url]; + // -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] } + Process { id: mkdirProc; command: ["mkdir", "-p", booruCol.cacheDir, booruCol.downloadsDir] } Component.onCompleted: { mkdirProc.running = true; booruCol.doSearch(); @@ -1253,7 +1932,12 @@ Rectangle { } } - Process { id: setWpProc; command: [] } + property bool wallpaperSetDone: false + Process { + id: setWpProc + command: [] + onExited: (code) => { if (code === 0) booruCol.wallpaperSetDone = true; } + } // -- Search bar -- Row { @@ -1350,40 +2034,102 @@ Rectangle { } } + // 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: 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 + 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(); + } + } } - 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(); + } + } + + 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(); + } } } } @@ -1456,8 +2202,8 @@ Rectangle { model: booruCol.results delegate: Rectangle { property var post: modelData - width: 92 - height: 92 + width: booruCol.tileSize + height: booruCol.tileSize color: Theme.bgAlt border.width: 1 border.color: (post.rating === "e" || post.rating === "q") ? Theme.danger : Theme.borderDim @@ -1476,6 +2222,7 @@ Rectangle { 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 @@ -1508,7 +2255,12 @@ Rectangle { // 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) + // 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 { @@ -1536,15 +2288,27 @@ Rectangle { } } } - Text { - anchors.verticalCenter: parent.verticalCenter - text: "Page" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 + // 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 { - anchors.verticalCenter: parent.verticalCenter width: 40; height: 26 color: Theme.bgAlt border.width: 1 @@ -1567,12 +2331,18 @@ Rectangle { } } } - Text { - anchors.verticalCenter: parent.verticalCenter - text: booruCol.totalPages > 0 ? ("of " + booruCol.totalPages) : "" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 + 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 @@ -1708,7 +2478,7 @@ Rectangle { 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; + var target = booruCol.downloadsDir + "/" + booruSettings.source + "_" + post.id + "." + ext; booruCol.downloadTarget = target; downloadProc.command = ["curl", "-sL", post.fileUrl, "-o", target]; booruCol.downloading = true; @@ -1726,8 +2496,8 @@ Rectangle { border.color: Theme.border Text { anchors.centerIn: parent - text: "Set as Wallpaper" - color: Theme.fg + text: booruCol.wallpaperSetDone ? "Wallpaper set!" : "Set as Wallpaper" + color: booruCol.wallpaperSetDone ? Theme.accent : Theme.fg font.family: "JetBrainsMono Nerd Font" font.pixelSize: 12 } @@ -1737,7 +2507,17 @@ Rectangle { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - setWpProc.command = ["wallpaper-set", booruCol.downloadedPath]; + 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; } } diff --git a/dotfiles/quickshell/Meter.qml b/dotfiles/quickshell/Meter.qml new file mode 100644 index 0000000..e2679b3 --- /dev/null +++ b/dotfiles/quickshell/Meter.qml @@ -0,0 +1,26 @@ +import QtQuick + +// Read-only horizontal usage bar β€” CPU/RAM/GPU/VRAM/disk meters in the +// System Monitor panel all need the same "filled fraction of a track" +// visual, just with a different value/color each time. +Item { + id: root + property real value: 0 // 0..1 + property color barColor: Theme.accent + implicitHeight: 8 + + Rectangle { + anchors.fill: parent + color: Theme.bgAlt + border.width: 1 + border.color: Theme.borderDim + } + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.margins: 1 + width: Math.max(0, (parent.width - 2) * Math.min(1, Math.max(0, root.value))) + color: root.barColor + } +} diff --git a/dotfiles/quickshell/RightBar.qml b/dotfiles/quickshell/RightBar.qml index 03400fb..3d00e8d 100644 --- a/dotfiles/quickshell/RightBar.qml +++ b/dotfiles/quickshell/RightBar.qml @@ -173,6 +173,16 @@ PanelWindow { active: root.activePanel === "devices" onClicked: root.toggle("devices") } + SidebarRow { + label: "System Monitor" + active: root.activePanel === "system" + onClicked: root.toggle("system") + } + SidebarRow { + label: "Weather" + active: root.activePanel === "weather" + onClicked: root.toggle("weather") + } } } diff --git a/dotfiles/quickshell/RightFlyout.qml b/dotfiles/quickshell/RightFlyout.qml index a274570..fa30709 100644 --- a/dotfiles/quickshell/RightFlyout.qml +++ b/dotfiles/quickshell/RightFlyout.qml @@ -24,6 +24,8 @@ Rectangle { volume: "Volume", brightness: "Brightness", devices: "Devices", + system: "System Monitor", + weather: "Weather", power: "Power" }) @@ -87,6 +89,8 @@ Rectangle { case "volume": return volumePanel; case "brightness": return brightnessPanel; case "devices": return devicesPanel; + case "system": return systemPanel; + case "weather": return weatherPanel; case "power": return powerPanel; default: return emptyPanel; } @@ -142,6 +146,19 @@ Rectangle { font.pixelSize: 11 } + // Hardware/rfkill state isn't reflected by wifiEnabled at all β€” without + // this, a soft- or hardware-killed adapter just looks identical to "no + // networks in range" with no way to tell the two apart. + Text { + visible: !Networking.wifiHardwareEnabled + text: "Wi-Fi is disabled in hardware (check airplane mode / rfkill switch)" + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 11 + wrapMode: Text.WordWrap + width: parent.width + } + Repeater { model: Networking.devices delegate: Column { @@ -149,11 +166,43 @@ Rectangle { spacing: 4 property var device: modelData + // Wired devices report a single phantom "network" entry with no + // signal strength, so detect Wi-Fi by scannerEnabled (only WifiDevice + // has that property) instead of by networks-list emptiness. + property bool isWifi: device.scannerEnabled !== undefined + + // The plugin exposes no "scan in progress" signal, just the + // scannerEnabled bool β€” so this is a grace-period guess, not a + // real status readout. Good enough to stop "no networks yet" + // from looking identical to "scan came back empty", which is + // what made this look like a silent bug before. + property bool scanning: false + Timer { + id: scanGrace + interval: 4000 + onTriggered: scanning = false + } + // Turn on active scanning while this panel is open so // the network list shows more than just whatever we're // already connected to. - Component.onCompleted: if (device.scannerEnabled !== undefined) device.scannerEnabled = true; - Component.onDestruction: if (device.scannerEnabled !== undefined) device.scannerEnabled = false; + Component.onCompleted: if (isWifi) { device.scannerEnabled = true; scanning = true; scanGrace.restart(); } + Component.onDestruction: if (isWifi) device.scannerEnabled = false; + + // NetworkManager throttles RequestScan, so a bare re-toggle right + // after the last one can be silently ignored β€” this is still just + // a best-effort nudge, not a guaranteed fresh scan. + function rescan() { + device.scannerEnabled = false; + scanning = true; + scanGrace.restart(); + rescanRestart.start(); + } + Timer { + id: rescanRestart + interval: 150 + onTriggered: device.scannerEnabled = true + } Text { text: device.name @@ -162,10 +211,27 @@ Rectangle { font.pixelSize: 10 } - // Wired devices report a single phantom "network" entry with no - // signal strength, so detect Wi-Fi by scannerEnabled (only WifiDevice - // has that property) instead of by networks-list emptiness. - property bool isWifi: device.scannerEnabled !== undefined + Rectangle { + visible: isWifi + width: loader.width + height: 26 + color: rescanMa.containsMouse ? Theme.bgAlt : "transparent" + border.width: 1 + border.color: Theme.borderDim + Text { + anchors.centerIn: parent + text: scanning ? "Scanning…" : "Rescan" + color: scanning ? Theme.accent : Theme.fg + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 11 + } + MouseArea { + id: rescanMa + anchors.fill: parent + hoverEnabled: true + onClicked: rescan() + } + } // Non-Wi-Fi devices β€” show link state directly instead of a list. Rectangle { @@ -192,6 +258,14 @@ Rectangle { } } + Text { + visible: isWifi && (!device.networks || device.networks.values.length === 0) + text: scanning ? "Scanning…" : "No networks found" + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 11 + } + Repeater { model: (isWifi && device.networks) ? device.networks : [] delegate: Rectangle { @@ -633,6 +707,22 @@ Rectangle { width: loader.width property var selected: null + // Same reasoning as the Wi-Fi panel's per-device scannerEnabled + // toggle β€” auto-start scanning while the panel is open instead + // of requiring a manual click every time, then stop when it + // closes so the adapter isn't left discovering in the background. + // Qt.callLater, not a direct write: writing `discovering` in the + // same tick this Column is constructed was silently dropped + // (verified against BlueZ directly over D-Bus β€” Discovering + // stayed false) even though the identical write from the + // manual "Scan for devices" button works fine; deferring one + // event-loop tick gives Quickshell's Bluetooth adapter wrapper + // time to finish attaching before the write goes out. + Component.onCompleted: Qt.callLater(function () { + if (Bluetooth.defaultAdapter && Bluetooth.defaultAdapter.enabled) Bluetooth.defaultAdapter.discovering = true; + }); + Component.onDestruction: if (Bluetooth.defaultAdapter) Bluetooth.defaultAdapter.discovering = false; + // --- List view --------------------------------------------- Column { visible: btCol.selected === null @@ -1441,6 +1531,491 @@ Rectangle { } } + // --- System Monitor ------------------------------------------------------- + // One process invocation per poll (system-telemetry, modules/scripts/ + // system-telemetry.sh) instead of separate Process objects per metric β€” + // it reads /proc/stat twice 200ms apart internally to compute CPU%, + // /proc/meminfo for RAM, nvidia-smi or the AMD sysfs counters for GPU/ + // VRAM (whichever's actually present), and `df -h` for / and /home β€” + // and returns it all as one JSON line. + Component { + id: systemPanel + Column { + id: sysCol + width: loader.width + spacing: 14 + property var stats: ({}) + property bool haveData: Object.keys(sysCol.stats).length > 0 + + Timer { + interval: 2000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: if (!telemetryProc.running) telemetryProc.running = true + } + Process { + id: telemetryProc + command: ["system-telemetry"] + stdout: StdioCollector { + onStreamFinished: { + try { sysCol.stats = JSON.parse(text); } catch (e) { /* keep last good reading */ } + } + } + } + + Text { + visible: !sysCol.haveData + text: "Reading system stats..." + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 12 + } + + // -- CPU -- + Column { + visible: sysCol.haveData + width: sysCol.width + spacing: 4 + Item { + width: parent.width + height: 16 + Text { + anchors.left: parent.left + text: "CPU" + color: Theme.fg + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 12 + font.bold: true + } + Text { + anchors.right: parent.right + text: (sysCol.stats.cpuPct !== undefined ? sysCol.stats.cpuPct : 0) + "%" + color: Theme.accent + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 12 + } + } + Meter { width: parent.width; value: (sysCol.stats.cpuPct || 0) / 100 } + } + + // -- RAM -- + Column { + visible: sysCol.haveData + width: sysCol.width + spacing: 4 + Item { + width: parent.width + height: 16 + Text { + anchors.left: parent.left + text: "Memory" + color: Theme.fg + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 12 + font.bold: true + } + Text { + anchors.right: parent.right + text: ((sysCol.stats.memUsedKb || 0) / 1048576).toFixed(1) + " / " + + ((sysCol.stats.memTotalKb || 0) / 1048576).toFixed(1) + " GB" + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 11 + } + } + Meter { + width: parent.width + value: sysCol.stats.memTotalKb > 0 ? sysCol.stats.memUsedKb / sysCol.stats.memTotalKb : 0 + } + } + + // -- GPU / VRAM -- + Text { + visible: sysCol.haveData && (sysCol.stats.gpuPct === undefined || sysCol.stats.gpuPct < 0) + text: "No GPU metrics available" + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 11 + } + Column { + visible: sysCol.haveData && sysCol.stats.gpuPct >= 0 + width: sysCol.width + spacing: 4 + Item { + width: parent.width + height: 16 + Text { + anchors.left: parent.left + text: "GPU" + color: Theme.fg + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 12 + font.bold: true + } + Text { + anchors.right: parent.right + text: (sysCol.stats.gpuPct !== undefined ? sysCol.stats.gpuPct : 0) + "%" + color: Theme.accent + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 12 + } + } + Meter { width: parent.width; value: (sysCol.stats.gpuPct || 0) / 100 } + } + Column { + visible: sysCol.haveData && sysCol.stats.vramTotalMb > 0 + width: sysCol.width + spacing: 4 + Item { + width: parent.width + height: 16 + Text { + anchors.left: parent.left + text: "VRAM" + color: Theme.fg + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 12 + font.bold: true + } + Text { + anchors.right: parent.right + text: (sysCol.stats.vramUsedMb || 0) + " / " + (sysCol.stats.vramTotalMb || 0) + " MB" + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 11 + } + } + Meter { + width: parent.width + value: sysCol.stats.vramTotalMb > 0 ? sysCol.stats.vramUsedMb / sysCol.stats.vramTotalMb : 0 + } + } + + // -- Disks -- + Column { + visible: sysCol.haveData && sysCol.stats.disks && sysCol.stats.disks.length > 0 + width: sysCol.width + spacing: 8 + Text { + text: "STORAGE" + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 9 + font.bold: true + } + Repeater { + model: sysCol.stats.disks || [] + delegate: Column { + width: parent.width + spacing: 4 + property var disk: modelData + Item { + width: parent.width + height: 16 + Text { + anchors.left: parent.left + text: disk.mount + color: Theme.fg + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 12 + } + Text { + anchors.right: parent.right + text: disk.used + " / " + disk.size + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 11 + } + } + Meter { width: parent.width; value: (parseFloat(disk.pct) || 0) / 100 } + } + } + } + } + } + + // --- Weather --------------------------------------------------------- + // weather-fetch (modules/scripts/weather-fetch.sh) does the actual + // network work β€” IP-geolocates via ip-api.com by default (or geocodes + // an explicitly typed city via Open-Meteo's own geocoding endpoint), + // then pulls current + hourly conditions from Open-Meteo. Both are + // free and keyless, matching curl everywhere else in this file rather + // than QML's own networking (Image loads already had to route around + // 403s from a CDN that didn't like Qt's request fingerprint β€” curl + // has been the reliable path for every other external fetch here). + Component { + id: weatherPanel + Column { + id: wxCol + width: loader.width + spacing: 12 + property var forecast: ({}) + property bool haveData: !!wxCol.forecast.current + property bool hasError: !!wxCol.forecast.error + + Item { + id: wxSettings + property string city: "" + property string stateFile: Quickshell.stateDir + "/weather-settings.json" + function save() { + wxSaveProc.command = ["sh", "-c", + "mkdir -p \"$(dirname \"$2\")\" && printf '%s' \"$1\" > \"$2\"", + "--", JSON.stringify({ city: wxSettings.city }), wxSettings.stateFile]; + wxSaveProc.running = true; + } + Process { id: wxSaveProc } + Process { + id: wxLoadProc + command: ["cat", wxSettings.stateFile] + stdout: StdioCollector { + onStreamFinished: { + try { + var d = JSON.parse(text); + if (d.city !== undefined) wxSettings.city = d.city; + } catch (e) { /* no state file yet β€” defaults stand */ } + wxCol.refresh(); + } + } + } + Component.onCompleted: wxLoadProc.running = true + } + + function refresh() { + fetchProc.command = wxSettings.city.length > 0 + ? ["weather-fetch", wxSettings.city] : ["weather-fetch"]; + fetchProc.running = true; + } + Process { + id: fetchProc + stdout: StdioCollector { + onStreamFinished: { + try { wxCol.forecast = JSON.parse(text); } catch (e) { wxCol.forecast = { error: "bad response" }; } + } + } + } + // Open-Meteo's current reading updates roughly every 15 + // minutes on their end β€” polling faster would just repeat the + // same numbers. + Timer { interval: 900000; running: true; repeat: true; onTriggered: wxCol.refresh() } + + // WMO weather codes -> a short label + a plain Unicode symbol + // (not a Nerd Font glyph β€” those need exact codepoints from + // the specific icon set and guessing wrong renders as a + // tofu box; these are plain emoji, safe under any font with + // emoji fallback, which every font on this system has). + function describeCode(code) { + if (code === 0) return { label: "Clear sky", icon: "β˜€" }; + if (code === 1) return { label: "Mostly clear", icon: "🌀" }; + if (code === 2) return { label: "Partly cloudy", icon: "β›…" }; + if (code === 3) return { label: "Overcast", icon: "☁" }; + if (code === 45 || code === 48) return { label: "Fog", icon: "🌫" }; + if (code >= 51 && code <= 57) return { label: "Drizzle", icon: "🌦" }; + if (code >= 61 && code <= 67) return { label: "Rain", icon: "🌧" }; + if (code >= 71 && code <= 77) return { label: "Snow", icon: "❄" }; + if (code >= 80 && code <= 82) return { label: "Showers", icon: "🌧" }; + if (code === 85 || code === 86) return { label: "Snow showers", icon: "🌨" }; + if (code >= 95) return { label: "Thunderstorm", icon: "β›ˆ" }; + return { label: "Unknown", icon: "?" }; + } + + // -- City search (blank = auto IP-geolocate) -- + Row { + width: wxCol.width + spacing: 6 + Rectangle { + width: parent.width - 66 + height: 30 + color: Theme.bgAlt + border.width: 1 + border.color: Theme.border + TextInput { + id: cityInput + anchors.fill: parent + anchors.margins: 8 + text: wxSettings.city + onTextEdited: wxSettings.city = text + color: Theme.fg + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 12 + clip: true + Keys.onReturnPressed: { wxSettings.save(); wxCol.forecast = {}; wxCol.refresh(); } + Text { + visible: cityInput.text.length === 0 + text: "City (blank = auto-detect)" + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 12 + } + } + } + Rectangle { + width: 60 + height: 30 + color: wxSearchMa.containsMouse ? Theme.bgAlt : "transparent" + border.width: 1 + border.color: Theme.accent + Text { + anchors.centerIn: parent + text: "Set" + color: Theme.accent + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 11 + } + MouseArea { + id: wxSearchMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { wxSettings.save(); wxCol.forecast = {}; wxCol.refresh(); } + } + } + } + + Text { + visible: !wxCol.haveData && !wxCol.hasError + text: "Loading weather..." + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 12 + } + Text { + visible: wxCol.hasError + text: "Couldn't load weather β€” check the city name or try again later." + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 11 + wrapMode: Text.WordWrap + width: wxCol.width + } + + // -- Current conditions -- + Column { + visible: wxCol.haveData + width: wxCol.width + spacing: 4 + + Text { + text: [wxCol.forecast.place, wxCol.forecast.country].filter((s) => s && s.length > 0).join(", ") + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 10 + } + + Row { + spacing: 12 + Text { + text: wxCol.haveData ? wxCol.describeCode(wxCol.forecast.current.weather_code).icon : "" + font.pixelSize: 40 + } + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + text: wxCol.haveData ? Math.round(wxCol.forecast.current.temperature_2m) + "Β°C" : "" + color: Theme.fg + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 24 + font.bold: true + } + Text { + text: wxCol.haveData ? wxCol.describeCode(wxCol.forecast.current.weather_code).label : "" + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 11 + } + } + } + + Row { + spacing: 16 + Text { + text: wxCol.haveData ? ("Wind: " + wxCol.forecast.current.wind_speed_10m + " km/h") : "" + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 10 + } + Text { + text: wxCol.haveData ? ("Precip: " + wxCol.forecast.current.precipitation + " mm") : "" + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 10 + } + } + } + + Rectangle { visible: wxCol.haveData; width: wxCol.width; height: 1; color: Theme.borderDim } + + // -- Hourly forecast timeline -- + Column { + visible: wxCol.haveData && !!wxCol.forecast.hourly + width: wxCol.width + spacing: 8 + Text { + text: "NEXT HOURS" + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 9 + font.bold: true + } + Flow { + width: wxCol.width + spacing: 6 + Repeater { + // Only future hours from "now" β€” Open-Meteo's hourly + // array starts at local midnight, not the current + // hour, so without this the timeline would open + // showing most of a day that's already passed. + model: { + if (!wxCol.haveData || !wxCol.forecast.hourly) return []; + var nowIso = wxCol.forecast.current.time.substring(0, 13); + var times = wxCol.forecast.hourly.time; + var startIdx = 0; + for (var i = 0; i < times.length; i++) { + if (times[i].substring(0, 13) >= nowIso) { startIdx = i; break; } + } + var out = []; + for (var j = startIdx; j < Math.min(startIdx + 8, times.length); j++) out.push(j); + return out; + } + delegate: Rectangle { + property int idx: modelData + width: (wxCol.width - 30) / 4 + height: 64 + color: Theme.bgAlt + border.width: 1 + border.color: Theme.borderDim + Column { + anchors.centerIn: parent + spacing: 2 + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: wxCol.forecast.hourly.time[idx].substring(11, 16) + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 9 + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: wxCol.describeCode(wxCol.forecast.hourly.weather_code[idx]).icon + font.pixelSize: 16 + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: Math.round(wxCol.forecast.hourly.temperature_2m[idx]) + "Β°" + color: Theme.fg + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 11 + font.bold: true + } + } + } + } + } + } + } + } + // --- Power ------------------------------------------------------- Component { id: powerPanel diff --git a/modules/scripts.nix b/modules/scripts.nix index ffa3cd7..574ee4e 100644 --- a/modules/scripts.nix +++ b/modules/scripts.nix @@ -9,6 +9,7 @@ let wallpaperSet = pkgs.writeShellScriptBin "wallpaper-set" (builtins.readFile ./scripts/wallpaper-set.sh); wallpaperDelete = pkgs.writeShellScriptBin "wallpaper-delete" (builtins.readFile ./scripts/wallpaper-delete.sh); wallpaperRestore = pkgs.writeShellScriptBin "wallpaper-restore" (builtins.readFile ./scripts/wallpaper-restore.sh); + wallpaperThumbs = pkgs.writeShellScriptBin "wallpaper-thumbs" (builtins.readFile ./scripts/wallpaper-thumbs.sh); displaySet = pkgs.writeShellScriptBin "display-set" (builtins.readFile ./scripts/display-set.sh); hyprThemeSet = pkgs.writeShellScriptBin "hypr-theme-set" (builtins.readFile ./scripts/hypr-theme-set.sh); gtkThemeSet = pkgs.writeShellScriptBin "gtk-theme-set" (builtins.readFile ./scripts/gtk-theme-set.sh); @@ -20,6 +21,8 @@ let cursorSet = pkgs.writeShellScriptBin "cursor-set" (builtins.readFile ./scripts/cursor-set.sh); screenshot = pkgs.writeShellScriptBin "screenshot" (builtins.readFile ./scripts/screenshot.sh); hotspotStart = pkgs.writeShellScriptBin "hotspot-start" (builtins.readFile ./scripts/hotspot-start.sh); + systemTelemetry = pkgs.writeShellScriptBin "system-telemetry" (builtins.readFile ./scripts/system-telemetry.sh); + weatherFetch = pkgs.writeShellScriptBin "weather-fetch" (builtins.readFile ./scripts/weather-fetch.sh); in { # Install CLI dependencies required by the scripts @@ -34,6 +37,7 @@ in wallpaperSet wallpaperDelete wallpaperRestore + wallpaperThumbs displaySet hyprThemeSet gtkThemeSet @@ -45,5 +49,7 @@ in cursorSet screenshot hotspotStart + systemTelemetry + weatherFetch ]; } \ No newline at end of file diff --git a/modules/scripts/display-set.sh b/modules/scripts/display-set.sh index b7c74f5..f73fdb1 100644 --- a/modules/scripts/display-set.sh +++ b/modules/scripts/display-set.sh @@ -1,23 +1,27 @@ #!/usr/bin/env bash -# Applies a monitor mode/position/scale/transform immediately (hyprctl) -# AND persists it into ~/.config/hypr/display.conf, which hyprland.conf -# sources β€” without that file, hyprland.conf's generic fallback rule +# Applies a full monitor descriptor immediately (hyprctl) AND persists it +# into ~/.config/hypr/display.conf, which hyprland.conf sources β€” without +# that file, hyprland.conf's generic fallback rule # ("monitor = , preferred, auto, 1") is the only thing on record, and # ANYTHING that makes Hyprland re-evaluate monitor state (observed: even # changing the cursor theme via `hyprctl setcursor` does this) reapplies # that fallback, silently dropping back to "preferred" refresh rate # instead of whatever the user actually picked. +# +# Takes the monitor name and everything after it as one pre-built +# descriptor string (mode,position,scale + optional transform/bitdepth/ +# cm/sdrbrightness/sdrsaturation/vrr/icc keyword,value pairs) rather than +# fixed positional args β€” Hyprland's own `monitor` keyword syntax grew +# past "mode/x/y/scale/transform" (color management, HDR, VRR, ICC), and +# the caller (LeftFlyout.qml's Displays panel) already has all of that as +# structured state, so it's simpler for it to build the one string than +# for this script to accept a growing positional argument list. set -euo pipefail -mon="${1:?usage: display-set [transform]}" -mode="${2:?mode required}" -x="${3:?x required}" -y="${4:?y required}" -scale="${5:?scale required}" -transform="${6:-0}" +mon="${1:?usage: display-set }" +descriptor_tail="${2:?descriptor required}" -descriptor="$mon,$mode,${x}x${y},$scale" -[ "$transform" != "0" ] && descriptor="$descriptor,transform,$transform" +descriptor="$mon,$descriptor_tail" conf="$HOME/.config/hypr/display.conf" mkdir -p "$(dirname "$conf")" diff --git a/modules/scripts/system-telemetry.sh b/modules/scripts/system-telemetry.sh new file mode 100755 index 0000000..89c72a3 --- /dev/null +++ b/modules/scripts/system-telemetry.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Single-shot system telemetry snapshot β€” CPU/RAM/GPU/VRAM/disk usage β€” as +# one JSON object, so the Quickshell System panel can poll this on a Timer +# instead of spawning half a dozen separate processes every tick. +set -euo pipefail + +# --- CPU --- +# /proc/stat's first line ("cpu ...") is cumulative jiffies since boot β€” +# computing instantaneous usage needs two samples across a short gap, so +# both reads happen inside this one invocation rather than needing +# cross-poll state kept on the QML side. +read_cpu() { + read -r _ user nice system idle iowait irq softirq steal _ < /proc/stat + echo "$((user + nice + system + irq + softirq + steal)) $((user + nice + system + idle + iowait + irq + softirq + steal))" +} +read -r cpu_busy1 cpu_total1 <<< "$(read_cpu)" +sleep 0.2 +read -r cpu_busy2 cpu_total2 <<< "$(read_cpu)" +cpu_busy_delta=$((cpu_busy2 - cpu_busy1)) +cpu_total_delta=$((cpu_total2 - cpu_total1)) +cpu_pct=0 +[ "$cpu_total_delta" -gt 0 ] && cpu_pct=$((100 * cpu_busy_delta / cpu_total_delta)) + +# --- RAM --- +mem_total_kb=$(awk '/^MemTotal:/{print $2}' /proc/meminfo) +mem_avail_kb=$(awk '/^MemAvailable:/{print $2}' /proc/meminfo) +mem_used_kb=$((mem_total_kb - mem_avail_kb)) + +# --- GPU / VRAM --- +# NVIDIA checked first via nvidia-smi (works regardless of which DRM card +# index the driver landed on); AMD falls back to the card0 sysfs counters +# directly β€” whichever's actually present decides which branch fires, so +# this works unmodified on either machine. +gpu_pct=-1 +vram_used_mb=-1 +vram_total_mb=-1 +if command -v nvidia-smi > /dev/null 2>&1 && nvidia-smi -L > /dev/null 2>&1; then + nv_out="$(nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total --format=csv,noheader,nounits -i 0 2>/dev/null | tr -d ' ')" + IFS=',' read -r gpu_pct vram_used_mb vram_total_mb <<< "$nv_out" +elif [ -e /sys/class/drm/card0/device/gpu_busy_percent ]; then + gpu_pct="$(cat /sys/class/drm/card0/device/gpu_busy_percent 2>/dev/null || echo -1)" + if [ -e /sys/class/drm/card0/device/mem_info_vram_used ]; then + vram_used_mb=$(($(cat /sys/class/drm/card0/device/mem_info_vram_used) / 1024 / 1024)) + vram_total_mb=$(($(cat /sys/class/drm/card0/device/mem_info_vram_total) / 1024 / 1024)) + fi +fi + +# --- Disks (root + home) --- +# Positional ($NF for mountpoint), not header-text matching β€” `df -h`'s +# header is locale-translated (verified live: "System plikΓ³w"/"%uΕΌ." under +# this machine's pl_PL locale, not "Filesystem"/"Use%"), so matching on +# English header text would silently find nothing. +disks_json="$(df -h 2>/dev/null | awk ' + NR == 1 { next } + $NF == "/" || $NF == "/home" { + gsub(/%/, "", $5) + printf "%s{\"mount\":\"%s\",\"size\":\"%s\",\"used\":\"%s\",\"avail\":\"%s\",\"pct\":\"%s\"}", (n++ ? "," : ""), $NF, $2, $3, $4, $5 + } +')" + +printf '{"cpuPct":%s,"memUsedKb":%s,"memTotalKb":%s,"gpuPct":%s,"vramUsedMb":%s,"vramTotalMb":%s,"disks":[%s]}\n' \ + "$cpu_pct" "$mem_used_kb" "$mem_total_kb" "$gpu_pct" "$vram_used_mb" "$vram_total_mb" "$disks_json" diff --git a/modules/scripts/wallpaper-thumbs.sh b/modules/scripts/wallpaper-thumbs.sh new file mode 100644 index 0000000..38897fc --- /dev/null +++ b/modules/scripts/wallpaper-thumbs.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Generates (or reuses) a cached preview JPEG for each animated wallpaper +# β€” first frame ~1s in, to skip a common black/fade intro on most clips β€” +# so the Wallpapers panel can show a real thumbnail instead of a generic +# play icon. Prints "\t" per file on stdout +# once it's ready, so the caller (LeftFlyout.qml) never has to +# independently reconstruct the cache filename β€” it's a hash of the +# source path, chosen only to keep filenames short/safe, not something +# QML needs to know how to derive. +set -euo pipefail + +cache_dir="${1:?usage: wallpaper-thumbs }" +shift +mkdir -p "$cache_dir" + +for file in "$@"; do + [ -f "$file" ] || continue + name="$(printf '%s' "$file" | sha1sum | cut -d' ' -f1)" + thumb="$cache_dir/$name.jpg" + if [ ! -f "$thumb" ] || [ "$file" -nt "$thumb" ]; then + ffmpeg -y -ss 00:00:01 -i "$file" -frames:v 1 -vf "scale=320:-1" "$thumb" \ + > /dev/null 2>&1 || true + fi + [ -f "$thumb" ] && printf '%s\t%s\n' "$file" "$thumb" +done diff --git a/modules/scripts/weather-fetch.sh b/modules/scripts/weather-fetch.sh new file mode 100755 index 0000000..5c83ec9 --- /dev/null +++ b/modules/scripts/weather-fetch.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Fetches current weather + a short hourly forecast from Open-Meteo (free, +# no API key/account needed) for either the machine's IP-geolocated +# position (default, via ip-api.com β€” also free/keyless) or an explicitly +# named city, and prints one combined JSON object. +set -euo pipefail + +city="${1:-}" + +if [ -n "$city" ]; then + encoded="$(jq -sRr @uri <<< "$city")" + geo="$(curl -s "https://geocoding-api.open-meteo.com/v1/search?count=1&name=$encoded")" + lat="$(jq -r '.results[0].latitude // empty' <<< "$geo")" + lon="$(jq -r '.results[0].longitude // empty' <<< "$geo")" + place="$(jq -r '.results[0].name // empty' <<< "$geo")" + country="$(jq -r '.results[0].country // empty' <<< "$geo")" +else + ipgeo="$(curl -s "http://ip-api.com/json/")" + lat="$(jq -r '.lat // empty' <<< "$ipgeo")" + lon="$(jq -r '.lon // empty' <<< "$ipgeo")" + place="$(jq -r '.city // empty' <<< "$ipgeo")" + country="$(jq -r '.country // empty' <<< "$ipgeo")" +fi + +if [ -z "$lat" ] || [ -z "$lon" ]; then + echo '{"error":"location lookup failed"}' + exit 0 +fi + +weather="$(curl -s "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon¤t=temperature_2m,weather_code,wind_speed_10m,precipitation&hourly=temperature_2m,weather_code,precipitation_probability&timezone=auto&forecast_days=2")" + +jq --arg place "$place" --arg country "$country" '. + {place: $place, country: $country}' <<< "$weather"