quickshell and hyprland additions

This commit is contained in:
2026-03-15 13:56:00 +02:00
parent c9c27d1554
commit 1ad06b82a6
509 changed files with 68371 additions and 19 deletions

View File

@@ -0,0 +1,157 @@
pragma Singleton
import qs.config
import Caelestia.Services
import Caelestia
import Quickshell
import Quickshell.Services.Pipewire
import QtQuick
Singleton {
id: root
property string previousSinkName: ""
property string previousSourceName: ""
readonly property var nodes: Pipewire.nodes.values.reduce((acc, node) => {
if (!node.isStream) {
if (node.isSink)
acc.sinks.push(node);
else if (node.audio)
acc.sources.push(node);
} else if (node.isStream && node.audio) {
// Application streams (output streams)
acc.streams.push(node);
}
return acc;
}, {
sources: [],
sinks: [],
streams: []
})
readonly property list<PwNode> sinks: nodes.sinks
readonly property list<PwNode> sources: nodes.sources
readonly property list<PwNode> streams: nodes.streams
readonly property PwNode sink: Pipewire.defaultAudioSink
readonly property PwNode source: Pipewire.defaultAudioSource
readonly property bool muted: !!sink?.audio?.muted
readonly property real volume: sink?.audio?.volume ?? 0
readonly property bool sourceMuted: !!source?.audio?.muted
readonly property real sourceVolume: source?.audio?.volume ?? 0
readonly property alias cava: cava
readonly property alias beatTracker: beatTracker
function setVolume(newVolume: real): void {
if (sink?.ready && sink?.audio) {
sink.audio.muted = false;
sink.audio.volume = Math.max(0, Math.min(Config.services.maxVolume, newVolume));
}
}
function incrementVolume(amount: real): void {
setVolume(volume + (amount || Config.services.audioIncrement));
}
function decrementVolume(amount: real): void {
setVolume(volume - (amount || Config.services.audioIncrement));
}
function setSourceVolume(newVolume: real): void {
if (source?.ready && source?.audio) {
source.audio.muted = false;
source.audio.volume = Math.max(0, Math.min(Config.services.maxVolume, newVolume));
}
}
function incrementSourceVolume(amount: real): void {
setSourceVolume(sourceVolume + (amount || Config.services.audioIncrement));
}
function decrementSourceVolume(amount: real): void {
setSourceVolume(sourceVolume - (amount || Config.services.audioIncrement));
}
function setAudioSink(newSink: PwNode): void {
Pipewire.preferredDefaultAudioSink = newSink;
}
function setAudioSource(newSource: PwNode): void {
Pipewire.preferredDefaultAudioSource = newSource;
}
function setStreamVolume(stream: PwNode, newVolume: real): void {
if (stream?.ready && stream?.audio) {
stream.audio.muted = false;
stream.audio.volume = Math.max(0, Math.min(Config.services.maxVolume, newVolume));
}
}
function setStreamMuted(stream: PwNode, muted: bool): void {
if (stream?.ready && stream?.audio) {
stream.audio.muted = muted;
}
}
function getStreamVolume(stream: PwNode): real {
return stream?.audio?.volume ?? 0;
}
function getStreamMuted(stream: PwNode): bool {
return !!stream?.audio?.muted;
}
function getStreamName(stream: PwNode): string {
if (!stream)
return qsTr("Unknown");
// Try application name first, then description, then name
return stream.applicationName || stream.description || stream.name || qsTr("Unknown Application");
}
onSinkChanged: {
if (!sink?.ready)
return;
const newSinkName = sink.description || sink.name || qsTr("Unknown Device");
if (previousSinkName && previousSinkName !== newSinkName && Config.utilities.toasts.audioOutputChanged)
Toaster.toast(qsTr("Audio output changed"), qsTr("Now using: %1").arg(newSinkName), "volume_up");
previousSinkName = newSinkName;
}
onSourceChanged: {
if (!source?.ready)
return;
const newSourceName = source.description || source.name || qsTr("Unknown Device");
if (previousSourceName && previousSourceName !== newSourceName && Config.utilities.toasts.audioInputChanged)
Toaster.toast(qsTr("Audio input changed"), qsTr("Now using: %1").arg(newSourceName), "mic");
previousSourceName = newSourceName;
}
Component.onCompleted: {
previousSinkName = sink?.description || sink?.name || qsTr("Unknown Device");
previousSourceName = source?.description || source?.name || qsTr("Unknown Device");
}
PwObjectTracker {
objects: [...root.sinks, ...root.sources, ...root.streams]
}
CavaProvider {
id: cava
bars: Config.services.visualiserBars
}
BeatTracker {
id: beatTracker
}
}

View File

@@ -0,0 +1,226 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.config
import qs.components.misc
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property list<var> ddcMonitors: []
readonly property list<Monitor> monitors: variants.instances
property bool appleDisplayPresent: false
function getMonitorForScreen(screen: ShellScreen): var {
return monitors.find(m => m.modelData === screen);
}
function getMonitor(query: string): var {
if (query === "active") {
return monitors.find(m => Hypr.monitorFor(m.modelData)?.focused);
}
if (query.startsWith("model:")) {
const model = query.slice(6);
return monitors.find(m => m.modelData.model === model);
}
if (query.startsWith("serial:")) {
const serial = query.slice(7);
return monitors.find(m => m.modelData.serialNumber === serial);
}
if (query.startsWith("id:")) {
const id = parseInt(query.slice(3), 10);
return monitors.find(m => Hypr.monitorFor(m.modelData)?.id === id);
}
return monitors.find(m => m.modelData.name === query);
}
function increaseBrightness(): void {
const monitor = getMonitor("active");
if (monitor)
monitor.setBrightness(monitor.brightness + Config.services.brightnessIncrement);
}
function decreaseBrightness(): void {
const monitor = getMonitor("active");
if (monitor)
monitor.setBrightness(monitor.brightness - Config.services.brightnessIncrement);
}
onMonitorsChanged: {
ddcMonitors = [];
ddcProc.running = true;
}
Variants {
id: variants
model: Quickshell.screens
Monitor {}
}
Process {
running: true
command: ["sh", "-c", "asdbctl get"] // To avoid warnings if asdbctl is not installed
stdout: StdioCollector {
onStreamFinished: root.appleDisplayPresent = text.trim().length > 0
}
}
Process {
id: ddcProc
command: ["ddcutil", "detect", "--brief"]
stdout: StdioCollector {
onStreamFinished: root.ddcMonitors = text.trim().split("\n\n").filter(d => d.startsWith("Display ")).map(d => ({
busNum: d.match(/I2C bus:[ ]*\/dev\/i2c-([0-9]+)/)[1],
connector: d.match(/DRM connector:\s+(.*)/)[1].replace(/^card\d+-/, "") // strip "card1-"
}))
}
}
CustomShortcut {
name: "brightnessUp"
description: "Increase brightness"
onPressed: root.increaseBrightness()
}
CustomShortcut {
name: "brightnessDown"
description: "Decrease brightness"
onPressed: root.decreaseBrightness()
}
IpcHandler {
target: "brightness"
function get(): real {
return getFor("active");
}
// Allows searching by active/model/serial/id/name
function getFor(query: string): real {
return root.getMonitor(query)?.brightness ?? -1;
}
function set(value: string): string {
return setFor("active", value);
}
// Handles brightness value like brightnessctl: 0.1, +0.1, 0.1-, 10%, +10%, 10%-
function setFor(query: string, value: string): string {
const monitor = root.getMonitor(query);
if (!monitor)
return "Invalid monitor: " + query;
let targetBrightness;
if (value.endsWith("%-")) {
const percent = parseFloat(value.slice(0, -2));
targetBrightness = monitor.brightness - (percent / 100);
} else if (value.startsWith("+") && value.endsWith("%")) {
const percent = parseFloat(value.slice(1, -1));
targetBrightness = monitor.brightness + (percent / 100);
} else if (value.endsWith("%")) {
const percent = parseFloat(value.slice(0, -1));
targetBrightness = percent / 100;
} else if (value.startsWith("+")) {
const increment = parseFloat(value.slice(1));
targetBrightness = monitor.brightness + increment;
} else if (value.endsWith("-")) {
const decrement = parseFloat(value.slice(0, -1));
targetBrightness = monitor.brightness - decrement;
} else if (value.includes("%") || value.includes("-") || value.includes("+")) {
return `Invalid brightness format: ${value}\nExpected: 0.1, +0.1, 0.1-, 10%, +10%, 10%-`;
} else {
targetBrightness = parseFloat(value);
}
if (isNaN(targetBrightness))
return `Failed to parse value: ${value}\nExpected: 0.1, +0.1, 0.1-, 10%, +10%, 10%-`;
monitor.setBrightness(targetBrightness);
return `Set monitor ${monitor.modelData.name} brightness to ${+monitor.brightness.toFixed(2)}`;
}
}
component Monitor: QtObject {
id: monitor
required property ShellScreen modelData
readonly property bool isDdc: root.ddcMonitors.some(m => m.connector === modelData.name)
readonly property string busNum: root.ddcMonitors.find(m => m.connector === modelData.name)?.busNum ?? ""
readonly property bool isAppleDisplay: root.appleDisplayPresent && modelData.model.startsWith("StudioDisplay")
property real brightness
property real queuedBrightness: NaN
readonly property Process initProc: Process {
stdout: StdioCollector {
onStreamFinished: {
if (monitor.isAppleDisplay) {
const val = parseInt(text.trim());
monitor.brightness = val / 101;
} else {
const [, , , cur, max] = text.split(" ");
monitor.brightness = parseInt(cur) / parseInt(max);
}
}
}
}
readonly property Timer timer: Timer {
interval: 500
onTriggered: {
if (!isNaN(monitor.queuedBrightness)) {
monitor.setBrightness(monitor.queuedBrightness);
monitor.queuedBrightness = NaN;
}
}
}
function setBrightness(value: real): void {
value = Math.max(0, Math.min(1, value));
const rounded = Math.round(value * 100);
if (Math.round(brightness * 100) === rounded)
return;
if (isDdc && timer.running) {
queuedBrightness = value;
return;
}
brightness = value;
if (isAppleDisplay)
Quickshell.execDetached(["asdbctl", "set", rounded]);
else if (isDdc)
Quickshell.execDetached(["ddcutil", "-b", busNum, "setvcp", "10", rounded]);
else
Quickshell.execDetached(["brightnessctl", "s", `${rounded}%`]);
if (isDdc)
timer.restart();
}
function initBrightness(): void {
if (isAppleDisplay)
initProc.command = ["asdbctl", "get"];
else if (isDdc)
initProc.command = ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"];
else
initProc.command = ["sh", "-c", "echo a b c $(brightnessctl g) $(brightnessctl m)"];
initProc.running = true;
}
onBusNumChanged: initBrightness()
Component.onCompleted: initBrightness()
}
}

View File

@@ -0,0 +1,237 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.config
import qs.utils
import Caelestia
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property bool showPreview
property string scheme
property string flavour
readonly property bool light: showPreview ? previewLight : currentLight
property bool currentLight
property bool previewLight
readonly property M3Palette palette: showPreview ? preview : current
readonly property M3TPalette tPalette: M3TPalette {}
readonly property M3Palette current: M3Palette {}
readonly property M3Palette preview: M3Palette {}
readonly property Transparency transparency: Transparency {}
readonly property alias wallLuminance: analyser.luminance
function getLuminance(c: color): real {
if (c.r == 0 && c.g == 0 && c.b == 0)
return 0;
return Math.sqrt(0.299 * (c.r ** 2) + 0.587 * (c.g ** 2) + 0.114 * (c.b ** 2));
}
function alterColour(c: color, a: real, layer: int): color {
const luminance = getLuminance(c);
const offset = (!light || layer == 1 ? 1 : -layer / 2) * (light ? 0.2 : 0.3) * (1 - transparency.base) * (1 + wallLuminance * (light ? (layer == 1 ? 3 : 1) : 2.5));
const scale = (luminance + offset) / luminance;
const r = Math.max(0, Math.min(1, c.r * scale));
const g = Math.max(0, Math.min(1, c.g * scale));
const b = Math.max(0, Math.min(1, c.b * scale));
return Qt.rgba(r, g, b, a);
}
function layer(c: color, layer: var): color {
if (!transparency.enabled)
return c;
return layer === 0 ? Qt.alpha(c, transparency.base) : alterColour(c, transparency.layers, layer ?? 1);
}
function on(c: color): color {
if (c.hslLightness < 0.5)
return Qt.hsla(c.hslHue, c.hslSaturation, 0.9, 1);
return Qt.hsla(c.hslHue, c.hslSaturation, 0.1, 1);
}
function load(data: string, isPreview: bool): void {
const colours = isPreview ? preview : current;
const scheme = JSON.parse(data);
if (!isPreview) {
root.scheme = scheme.name;
flavour = scheme.flavour;
currentLight = scheme.mode === "light";
} else {
previewLight = scheme.mode === "light";
}
for (const [name, colour] of Object.entries(scheme.colours)) {
const propName = name.startsWith("term") ? name : `m3${name}`;
if (colours.hasOwnProperty(propName))
colours[propName] = `#${colour}`;
}
}
function setMode(mode: string): void {
Quickshell.execDetached(["caelestia", "scheme", "set", "--notify", "-m", mode]);
}
FileView {
path: `${Paths.state}/scheme.json`
watchChanges: true
onFileChanged: reload()
onLoaded: root.load(text(), false)
}
ImageAnalyser {
id: analyser
source: Wallpapers.current
}
component Transparency: QtObject {
readonly property bool enabled: Appearance.transparency.enabled
readonly property real base: Appearance.transparency.base - (root.light ? 0.1 : 0)
readonly property real layers: Appearance.transparency.layers
}
component M3TPalette: QtObject {
readonly property color m3primary_paletteKeyColor: root.layer(root.palette.m3primary_paletteKeyColor)
readonly property color m3secondary_paletteKeyColor: root.layer(root.palette.m3secondary_paletteKeyColor)
readonly property color m3tertiary_paletteKeyColor: root.layer(root.palette.m3tertiary_paletteKeyColor)
readonly property color m3neutral_paletteKeyColor: root.layer(root.palette.m3neutral_paletteKeyColor)
readonly property color m3neutral_variant_paletteKeyColor: root.layer(root.palette.m3neutral_variant_paletteKeyColor)
readonly property color m3background: root.layer(root.palette.m3background, 0)
readonly property color m3onBackground: root.layer(root.palette.m3onBackground)
readonly property color m3surface: root.layer(root.palette.m3surface, 0)
readonly property color m3surfaceDim: root.layer(root.palette.m3surfaceDim, 0)
readonly property color m3surfaceBright: root.layer(root.palette.m3surfaceBright, 0)
readonly property color m3surfaceContainerLowest: root.layer(root.palette.m3surfaceContainerLowest)
readonly property color m3surfaceContainerLow: root.layer(root.palette.m3surfaceContainerLow)
readonly property color m3surfaceContainer: root.layer(root.palette.m3surfaceContainer)
readonly property color m3surfaceContainerHigh: root.layer(root.palette.m3surfaceContainerHigh)
readonly property color m3surfaceContainerHighest: root.layer(root.palette.m3surfaceContainerHighest)
readonly property color m3onSurface: root.layer(root.palette.m3onSurface)
readonly property color m3surfaceVariant: root.layer(root.palette.m3surfaceVariant, 0)
readonly property color m3onSurfaceVariant: root.layer(root.palette.m3onSurfaceVariant)
readonly property color m3inverseSurface: root.layer(root.palette.m3inverseSurface, 0)
readonly property color m3inverseOnSurface: root.layer(root.palette.m3inverseOnSurface)
readonly property color m3outline: root.layer(root.palette.m3outline)
readonly property color m3outlineVariant: root.layer(root.palette.m3outlineVariant)
readonly property color m3shadow: root.layer(root.palette.m3shadow)
readonly property color m3scrim: root.layer(root.palette.m3scrim)
readonly property color m3surfaceTint: root.layer(root.palette.m3surfaceTint)
readonly property color m3primary: root.layer(root.palette.m3primary)
readonly property color m3onPrimary: root.layer(root.palette.m3onPrimary)
readonly property color m3primaryContainer: root.layer(root.palette.m3primaryContainer)
readonly property color m3onPrimaryContainer: root.layer(root.palette.m3onPrimaryContainer)
readonly property color m3inversePrimary: root.layer(root.palette.m3inversePrimary)
readonly property color m3secondary: root.layer(root.palette.m3secondary)
readonly property color m3onSecondary: root.layer(root.palette.m3onSecondary)
readonly property color m3secondaryContainer: root.layer(root.palette.m3secondaryContainer)
readonly property color m3onSecondaryContainer: root.layer(root.palette.m3onSecondaryContainer)
readonly property color m3tertiary: root.layer(root.palette.m3tertiary)
readonly property color m3onTertiary: root.layer(root.palette.m3onTertiary)
readonly property color m3tertiaryContainer: root.layer(root.palette.m3tertiaryContainer)
readonly property color m3onTertiaryContainer: root.layer(root.palette.m3onTertiaryContainer)
readonly property color m3error: root.layer(root.palette.m3error)
readonly property color m3onError: root.layer(root.palette.m3onError)
readonly property color m3errorContainer: root.layer(root.palette.m3errorContainer)
readonly property color m3onErrorContainer: root.layer(root.palette.m3onErrorContainer)
readonly property color m3success: root.layer(root.palette.m3success)
readonly property color m3onSuccess: root.layer(root.palette.m3onSuccess)
readonly property color m3successContainer: root.layer(root.palette.m3successContainer)
readonly property color m3onSuccessContainer: root.layer(root.palette.m3onSuccessContainer)
readonly property color m3primaryFixed: root.layer(root.palette.m3primaryFixed)
readonly property color m3primaryFixedDim: root.layer(root.palette.m3primaryFixedDim)
readonly property color m3onPrimaryFixed: root.layer(root.palette.m3onPrimaryFixed)
readonly property color m3onPrimaryFixedVariant: root.layer(root.palette.m3onPrimaryFixedVariant)
readonly property color m3secondaryFixed: root.layer(root.palette.m3secondaryFixed)
readonly property color m3secondaryFixedDim: root.layer(root.palette.m3secondaryFixedDim)
readonly property color m3onSecondaryFixed: root.layer(root.palette.m3onSecondaryFixed)
readonly property color m3onSecondaryFixedVariant: root.layer(root.palette.m3onSecondaryFixedVariant)
readonly property color m3tertiaryFixed: root.layer(root.palette.m3tertiaryFixed)
readonly property color m3tertiaryFixedDim: root.layer(root.palette.m3tertiaryFixedDim)
readonly property color m3onTertiaryFixed: root.layer(root.palette.m3onTertiaryFixed)
readonly property color m3onTertiaryFixedVariant: root.layer(root.palette.m3onTertiaryFixedVariant)
}
component M3Palette: QtObject {
property color m3primary_paletteKeyColor: "#a8627b"
property color m3secondary_paletteKeyColor: "#8e6f78"
property color m3tertiary_paletteKeyColor: "#986e4c"
property color m3neutral_paletteKeyColor: "#807477"
property color m3neutral_variant_paletteKeyColor: "#837377"
property color m3background: "#191114"
property color m3onBackground: "#efdfe2"
property color m3surface: "#191114"
property color m3surfaceDim: "#191114"
property color m3surfaceBright: "#403739"
property color m3surfaceContainerLowest: "#130c0e"
property color m3surfaceContainerLow: "#22191c"
property color m3surfaceContainer: "#261d20"
property color m3surfaceContainerHigh: "#31282a"
property color m3surfaceContainerHighest: "#3c3235"
property color m3onSurface: "#efdfe2"
property color m3surfaceVariant: "#514347"
property color m3onSurfaceVariant: "#d5c2c6"
property color m3inverseSurface: "#efdfe2"
property color m3inverseOnSurface: "#372e30"
property color m3outline: "#9e8c91"
property color m3outlineVariant: "#514347"
property color m3shadow: "#000000"
property color m3scrim: "#000000"
property color m3surfaceTint: "#ffb0ca"
property color m3primary: "#ffb0ca"
property color m3onPrimary: "#541d34"
property color m3primaryContainer: "#6f334a"
property color m3onPrimaryContainer: "#ffd9e3"
property color m3inversePrimary: "#8b4a62"
property color m3secondary: "#e2bdc7"
property color m3onSecondary: "#422932"
property color m3secondaryContainer: "#5a3f48"
property color m3onSecondaryContainer: "#ffd9e3"
property color m3tertiary: "#f0bc95"
property color m3onTertiary: "#48290c"
property color m3tertiaryContainer: "#b58763"
property color m3onTertiaryContainer: "#000000"
property color m3error: "#ffb4ab"
property color m3onError: "#690005"
property color m3errorContainer: "#93000a"
property color m3onErrorContainer: "#ffdad6"
property color m3success: "#B5CCBA"
property color m3onSuccess: "#213528"
property color m3successContainer: "#374B3E"
property color m3onSuccessContainer: "#D1E9D6"
property color m3primaryFixed: "#ffd9e3"
property color m3primaryFixedDim: "#ffb0ca"
property color m3onPrimaryFixed: "#39071f"
property color m3onPrimaryFixedVariant: "#6f334a"
property color m3secondaryFixed: "#ffd9e3"
property color m3secondaryFixedDim: "#e2bdc7"
property color m3onSecondaryFixed: "#2b151d"
property color m3onSecondaryFixedVariant: "#5a3f48"
property color m3tertiaryFixed: "#ffdcc3"
property color m3tertiaryFixedDim: "#f0bc95"
property color m3onTertiaryFixed: "#2f1500"
property color m3onTertiaryFixedVariant: "#623f21"
property color term0: "#353434"
property color term1: "#ff4c8a"
property color term2: "#ffbbb7"
property color term3: "#ffdedf"
property color term4: "#b3a2d5"
property color term5: "#e98fb0"
property color term6: "#ffba93"
property color term7: "#eed1d2"
property color term8: "#b39e9e"
property color term9: "#ff80a3"
property color term10: "#ffd3d0"
property color term11: "#fff1f0"
property color term12: "#dcbc93"
property color term13: "#f9a8c2"
property color term14: "#ffd1c0"
property color term15: "#ffffff"
}
}

View File

@@ -0,0 +1,76 @@
pragma Singleton
import qs.services
import qs.config
import Caelestia
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property alias enabled: props.enabled
function setDynamicConfs(): void {
Hypr.extras.applyOptions({
"animations:enabled": 0,
"decoration:shadow:enabled": 0,
"decoration:blur:enabled": 0,
"general:gaps_in": 0,
"general:gaps_out": 0,
"general:border_size": 1,
"decoration:rounding": 0,
"general:allow_tearing": 1
});
}
onEnabledChanged: {
if (enabled) {
setDynamicConfs();
if (Config.utilities.toasts.gameModeChanged)
Toaster.toast(qsTr("Game mode enabled"), qsTr("Disabled Hyprland animations, blur, gaps and shadows"), "gamepad");
} else {
Hypr.extras.message("reload");
if (Config.utilities.toasts.gameModeChanged)
Toaster.toast(qsTr("Game mode disabled"), qsTr("Hyprland settings restored"), "gamepad");
}
}
PersistentProperties {
id: props
property bool enabled: Hypr.options["animations:enabled"] === 0
reloadableId: "gameMode"
}
Connections {
target: Hypr
function onConfigReloaded(): void {
if (props.enabled)
root.setDynamicConfs();
}
}
IpcHandler {
target: "gameMode"
function isEnabled(): bool {
return props.enabled;
}
function toggle(): void {
props.enabled = !props.enabled;
}
function enable(): void {
props.enabled = true;
}
function disable(): void {
props.enabled = false;
}
}
}

View File

@@ -0,0 +1,213 @@
pragma Singleton
import qs.components.misc
import qs.config
import Caelestia
import Caelestia.Internal
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property var toplevels: Hyprland.toplevels
readonly property var workspaces: Hyprland.workspaces
readonly property var monitors: Hyprland.monitors
readonly property HyprlandToplevel activeToplevel: Hyprland.activeToplevel?.wayland?.activated ? Hyprland.activeToplevel : null
readonly property HyprlandWorkspace focusedWorkspace: Hyprland.focusedWorkspace
readonly property HyprlandMonitor focusedMonitor: Hyprland.focusedMonitor
readonly property int activeWsId: focusedWorkspace?.id ?? 1
readonly property HyprKeyboard keyboard: extras.devices.keyboards.find(kb => kb.main) ?? null
readonly property bool capsLock: keyboard?.capsLock ?? false
readonly property bool numLock: keyboard?.numLock ?? false
readonly property string defaultKbLayout: keyboard?.layout.split(",")[0] ?? "??"
readonly property string kbLayoutFull: keyboard?.activeKeymap ?? "Unknown"
readonly property string kbLayout: kbMap.get(kbLayoutFull) ?? "??"
readonly property var kbMap: new Map()
readonly property alias extras: extras
readonly property alias options: extras.options
readonly property alias devices: extras.devices
property bool hadKeyboard
property string lastSpecialWorkspace: ""
signal configReloaded
function dispatch(request: string): void {
Hyprland.dispatch(request);
}
function cycleSpecialWorkspace(direction: string): void {
const openSpecials = workspaces.values.filter(w => w.name.startsWith("special:") && w.lastIpcObject.windows > 0);
if (openSpecials.length === 0)
return;
const activeSpecial = focusedMonitor.lastIpcObject.specialWorkspace.name ?? "";
if (!activeSpecial) {
if (lastSpecialWorkspace) {
const workspace = workspaces.values.find(w => w.name === lastSpecialWorkspace);
if (workspace && workspace.lastIpcObject.windows > 0) {
dispatch(`workspace ${lastSpecialWorkspace}`);
return;
}
}
dispatch(`workspace ${openSpecials[0].name}`);
return;
}
const currentIndex = openSpecials.findIndex(w => w.name === activeSpecial);
let nextIndex = 0;
if (currentIndex !== -1) {
if (direction === "next")
nextIndex = (currentIndex + 1) % openSpecials.length;
else
nextIndex = (currentIndex - 1 + openSpecials.length) % openSpecials.length;
}
dispatch(`workspace ${openSpecials[nextIndex].name}`);
}
function monitorFor(screen: ShellScreen): HyprlandMonitor {
return Hyprland.monitorFor(screen);
}
function reloadDynamicConfs(): void {
extras.batchMessage(["keyword bindlni ,Caps_Lock,global,caelestia:refreshDevices", "keyword bindlni ,Num_Lock,global,caelestia:refreshDevices"]);
}
Component.onCompleted: reloadDynamicConfs()
onCapsLockChanged: {
if (!Config.utilities.toasts.capsLockChanged)
return;
if (capsLock)
Toaster.toast(qsTr("Caps lock enabled"), qsTr("Caps lock is currently enabled"), "keyboard_capslock_badge");
else
Toaster.toast(qsTr("Caps lock disabled"), qsTr("Caps lock is currently disabled"), "keyboard_capslock");
}
onNumLockChanged: {
if (!Config.utilities.toasts.numLockChanged)
return;
if (numLock)
Toaster.toast(qsTr("Num lock enabled"), qsTr("Num lock is currently enabled"), "looks_one");
else
Toaster.toast(qsTr("Num lock disabled"), qsTr("Num lock is currently disabled"), "timer_1");
}
onKbLayoutFullChanged: {
if (hadKeyboard && Config.utilities.toasts.kbLayoutChanged)
Toaster.toast(qsTr("Keyboard layout changed"), qsTr("Layout changed to: %1").arg(kbLayoutFull), "keyboard");
hadKeyboard = !!keyboard;
}
Connections {
target: Hyprland
function onRawEvent(event: HyprlandEvent): void {
const n = event.name;
if (n.endsWith("v2"))
return;
if (n === "configreloaded") {
root.configReloaded();
root.reloadDynamicConfs();
} else if (["workspace", "moveworkspace", "activespecial", "focusedmon"].includes(n)) {
Hyprland.refreshWorkspaces();
Hyprland.refreshMonitors();
} else if (["openwindow", "closewindow", "movewindow"].includes(n)) {
Hyprland.refreshToplevels();
Hyprland.refreshWorkspaces();
} else if (n.includes("mon")) {
Hyprland.refreshMonitors();
} else if (n.includes("workspace")) {
Hyprland.refreshWorkspaces();
} else if (n.includes("window") || n.includes("group") || ["pin", "fullscreen", "changefloatingmode", "minimize"].includes(n)) {
Hyprland.refreshToplevels();
}
}
}
Connections {
target: root.focusedMonitor
function onLastIpcObjectChanged(): void {
const specialName = root.focusedMonitor.lastIpcObject.specialWorkspace.name;
if (specialName && specialName.startsWith("special:")) {
root.lastSpecialWorkspace = specialName;
}
}
}
FileView {
id: kbLayoutFile
path: Quickshell.env("CAELESTIA_XKB_RULES_PATH") || "/usr/share/X11/xkb/rules/base.lst"
onLoaded: {
const layoutMatch = text().match(/! layout\n([\s\S]*?)\n\n/);
if (layoutMatch) {
const lines = layoutMatch[1].split("\n");
for (const line of lines) {
if (!line.trim() || line.trim().startsWith("!"))
continue;
const match = line.match(/^\s*([a-z]{2,})\s+([a-zA-Z() ]+)$/);
if (match)
root.kbMap.set(match[2], match[1]);
}
}
const variantMatch = text().match(/! variant\n([\s\S]*?)\n\n/);
if (variantMatch) {
const lines = variantMatch[1].split("\n");
for (const line of lines) {
if (!line.trim() || line.trim().startsWith("!"))
continue;
const match = line.match(/^\s*([a-zA-Z0-9_-]+)\s+([a-z]{2,}): (.+)$/);
if (match)
root.kbMap.set(match[3], match[2]);
}
}
}
}
IpcHandler {
target: "hypr"
function refreshDevices(): void {
extras.refreshDevices();
}
function cycleSpecialWorkspace(direction: string): void {
root.cycleSpecialWorkspace(direction);
}
function listSpecialWorkspaces(): string {
return root.workspaces.values.filter(w => w.name.startsWith("special:") && w.lastIpcObject.windows > 0).map(w => w.name).join("\n");
}
}
CustomShortcut {
name: "refreshDevices"
description: "Reload devices"
onPressed: extras.refreshDevices()
onReleased: extras.refreshDevices()
}
HyprExtras {
id: extras
}
}

View File

@@ -0,0 +1,56 @@
pragma Singleton
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
Singleton {
id: root
property alias enabled: props.enabled
readonly property alias enabledSince: props.enabledSince
onEnabledChanged: {
if (enabled)
props.enabledSince = new Date();
}
PersistentProperties {
id: props
property bool enabled
property date enabledSince
reloadableId: "idleInhibitor"
}
IdleInhibitor {
enabled: props.enabled
window: PanelWindow {
implicitWidth: 0
implicitHeight: 0
color: "transparent"
mask: Region {}
}
}
IpcHandler {
target: "idleInhibitor"
function isEnabled(): bool {
return props.enabled;
}
function toggle(): void {
props.enabled = !props.enabled;
}
function enable(): void {
props.enabled = true;
}
function disable(): void {
props.enabled = false;
}
}
}

View File

@@ -0,0 +1,324 @@
pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
Singleton {
id: root
Component.onCompleted: {
// Trigger ethernet device detection after initialization
Qt.callLater(() => {
getEthernetDevices();
});
// Load saved connections on startup
Nmcli.loadSavedConnections(() => {
root.savedConnections = Nmcli.savedConnections;
root.savedConnectionSsids = Nmcli.savedConnectionSsids;
});
// Get initial WiFi status
Nmcli.getWifiStatus(enabled => {
root.wifiEnabled = enabled;
});
// Sync networks from Nmcli on startup
Qt.callLater(() => {
syncNetworksFromNmcli();
}, 100);
}
readonly property list<AccessPoint> networks: []
readonly property AccessPoint active: networks.find(n => n.active) ?? null
property bool wifiEnabled: true
readonly property bool scanning: Nmcli.scanning
property list<var> ethernetDevices: []
readonly property var activeEthernet: ethernetDevices.find(d => d.connected) ?? null
property int ethernetDeviceCount: 0
property bool ethernetProcessRunning: false
property var ethernetDeviceDetails: null
property var wirelessDeviceDetails: null
function enableWifi(enabled: bool): void {
Nmcli.enableWifi(enabled, result => {
if (result.success) {
root.getWifiStatus();
Nmcli.getNetworks(() => {
syncNetworksFromNmcli();
});
}
});
}
function toggleWifi(): void {
Nmcli.toggleWifi(result => {
if (result.success) {
root.getWifiStatus();
Nmcli.getNetworks(() => {
syncNetworksFromNmcli();
});
}
});
}
function rescanWifi(): void {
Nmcli.rescanWifi();
}
property var pendingConnection: null
signal connectionFailed(string ssid)
function connectToNetwork(ssid: string, password: string, bssid: string, callback: var): void {
// Set up pending connection tracking if callback provided
if (callback) {
const hasBssid = bssid !== undefined && bssid !== null && bssid.length > 0;
root.pendingConnection = {
ssid: ssid,
bssid: hasBssid ? bssid : "",
callback: callback
};
}
Nmcli.connectToNetwork(ssid, password, bssid, result => {
if (result && result.success) {
// Connection successful
if (callback)
callback(result);
root.pendingConnection = null;
} else if (result && result.needsPassword) {
// Password needed - callback will handle showing dialog
if (callback)
callback(result);
} else {
// Connection failed
if (result && result.error) {
root.connectionFailed(ssid);
}
if (callback)
callback(result);
root.pendingConnection = null;
}
});
}
function connectToNetworkWithPasswordCheck(ssid: string, isSecure: bool, callback: var, bssid: string): void {
// Set up pending connection tracking
const hasBssid = bssid !== undefined && bssid !== null && bssid.length > 0;
root.pendingConnection = {
ssid: ssid,
bssid: hasBssid ? bssid : "",
callback: callback
};
Nmcli.connectToNetworkWithPasswordCheck(ssid, isSecure, result => {
if (result && result.success) {
// Connection successful
if (callback)
callback(result);
root.pendingConnection = null;
} else if (result && result.needsPassword) {
// Password needed - callback will handle showing dialog
if (callback)
callback(result);
} else {
// Connection failed
if (result && result.error) {
root.connectionFailed(ssid);
}
if (callback)
callback(result);
root.pendingConnection = null;
}
}, bssid);
}
function disconnectFromNetwork(): void {
// Try to disconnect - use connection name if available, otherwise use device
Nmcli.disconnectFromNetwork();
// Refresh network list after disconnection
Qt.callLater(() => {
Nmcli.getNetworks(() => {
syncNetworksFromNmcli();
});
}, 500);
}
function forgetNetwork(ssid: string): void {
// Delete the connection profile for this network
// This will remove the saved password and connection settings
Nmcli.forgetNetwork(ssid, result => {
if (result.success) {
// Refresh network list after deletion
Qt.callLater(() => {
Nmcli.getNetworks(() => {
syncNetworksFromNmcli();
});
}, 500);
}
});
}
property list<string> savedConnections: []
property list<string> savedConnectionSsids: []
// Sync saved connections from Nmcli when they're updated
Connections {
target: Nmcli
function onSavedConnectionsChanged() {
root.savedConnections = Nmcli.savedConnections;
}
function onSavedConnectionSsidsChanged() {
root.savedConnectionSsids = Nmcli.savedConnectionSsids;
}
}
function syncNetworksFromNmcli(): void {
const rNetworks = root.networks;
const nNetworks = Nmcli.networks;
// Build a map of existing networks by key
const existingMap = new Map();
for (const rn of rNetworks) {
const key = `${rn.frequency}:${rn.ssid}:${rn.bssid}`;
existingMap.set(key, rn);
}
// Build a map of new networks by key
const newMap = new Map();
for (const nn of nNetworks) {
const key = `${nn.frequency}:${nn.ssid}:${nn.bssid}`;
newMap.set(key, nn);
}
// Remove networks that no longer exist
for (const [key, network] of existingMap) {
if (!newMap.has(key)) {
const index = rNetworks.indexOf(network);
if (index >= 0) {
rNetworks.splice(index, 1);
network.destroy();
}
}
}
// Add or update networks from Nmcli
for (const [key, nNetwork] of newMap) {
const existing = existingMap.get(key);
if (existing) {
// Update existing network's lastIpcObject
existing.lastIpcObject = nNetwork.lastIpcObject;
} else {
// Create new AccessPoint from Nmcli's data
rNetworks.push(apComp.createObject(root, {
lastIpcObject: nNetwork.lastIpcObject
}));
}
}
}
component AccessPoint: QtObject {
required property var lastIpcObject
readonly property string ssid: lastIpcObject.ssid
readonly property string bssid: lastIpcObject.bssid
readonly property int strength: lastIpcObject.strength
readonly property int frequency: lastIpcObject.frequency
readonly property bool active: lastIpcObject.active
readonly property string security: lastIpcObject.security
readonly property bool isSecure: security.length > 0
}
Component {
id: apComp
AccessPoint {}
}
function hasSavedProfile(ssid: string): bool {
// Use Nmcli's hasSavedProfile which has the same logic
return Nmcli.hasSavedProfile(ssid);
}
function getWifiStatus(): void {
Nmcli.getWifiStatus(enabled => {
root.wifiEnabled = enabled;
});
}
function getEthernetDevices(): void {
root.ethernetProcessRunning = true;
Nmcli.getEthernetInterfaces(interfaces => {
root.ethernetDevices = Nmcli.ethernetDevices;
root.ethernetDeviceCount = Nmcli.ethernetDevices.length;
root.ethernetProcessRunning = false;
});
}
function connectEthernet(connectionName: string, interfaceName: string): void {
Nmcli.connectEthernet(connectionName, interfaceName, result => {
if (result.success) {
getEthernetDevices();
// Refresh device details after connection
Qt.callLater(() => {
const activeDevice = root.ethernetDevices.find(function (d) {
return d.connected;
});
if (activeDevice && activeDevice.interface) {
updateEthernetDeviceDetails(activeDevice.interface);
}
}, 1000);
}
});
}
function disconnectEthernet(connectionName: string): void {
Nmcli.disconnectEthernet(connectionName, result => {
if (result.success) {
getEthernetDevices();
// Clear device details after disconnection
Qt.callLater(() => {
root.ethernetDeviceDetails = null;
});
}
});
}
function updateEthernetDeviceDetails(interfaceName: string): void {
Nmcli.getEthernetDeviceDetails(interfaceName, details => {
root.ethernetDeviceDetails = details;
});
}
function updateWirelessDeviceDetails(): void {
// Find the wireless interface by looking for wifi devices
// Pass empty string to let Nmcli find the active interface automatically
Nmcli.getWirelessDeviceDetails("", details => {
root.wirelessDeviceDetails = details;
});
}
function cidrToSubnetMask(cidr: string): string {
// Convert CIDR notation (e.g., "24") to subnet mask (e.g., "255.255.255.0")
const cidrNum = parseInt(cidr);
if (isNaN(cidrNum) || cidrNum < 0 || cidrNum > 32) {
return "";
}
const mask = (0xffffffff << (32 - cidrNum)) >>> 0;
const octets = [(mask >>> 24) & 0xff, (mask >>> 16) & 0xff, (mask >>> 8) & 0xff, mask & 0xff];
return octets.join(".");
}
Process {
running: true
command: ["nmcli", "m"]
stdout: SplitParser {
onRead: {
Nmcli.getNetworks(() => {
syncNetworksFromNmcli();
});
getEthernetDevices();
}
}
}
}

View File

@@ -0,0 +1,233 @@
pragma Singleton
import qs.config
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property int refCount: 0
// Current speeds in bytes per second
readonly property real downloadSpeed: _downloadSpeed
readonly property real uploadSpeed: _uploadSpeed
// Total bytes transferred since tracking started
readonly property real downloadTotal: _downloadTotal
readonly property real uploadTotal: _uploadTotal
// History of speeds for sparkline (most recent at end)
readonly property var downloadHistory: _downloadHistory
readonly property var uploadHistory: _uploadHistory
readonly property int historyLength: 30
// Private properties
property real _downloadSpeed: 0
property real _uploadSpeed: 0
property real _downloadTotal: 0
property real _uploadTotal: 0
property var _downloadHistory: []
property var _uploadHistory: []
// Previous readings for calculating speed
property real _prevRxBytes: 0
property real _prevTxBytes: 0
property real _prevTimestamp: 0
// Initial readings for calculating totals
property real _initialRxBytes: 0
property real _initialTxBytes: 0
property bool _initialized: false
function formatBytes(bytes: real): var {
// Handle negative or invalid values
if (bytes < 0 || isNaN(bytes) || !isFinite(bytes)) {
return {
value: 0,
unit: "B/s"
};
}
if (bytes < 1024) {
return {
value: bytes,
unit: "B/s"
};
} else if (bytes < 1024 * 1024) {
return {
value: bytes / 1024,
unit: "KB/s"
};
} else if (bytes < 1024 * 1024 * 1024) {
return {
value: bytes / (1024 * 1024),
unit: "MB/s"
};
} else {
return {
value: bytes / (1024 * 1024 * 1024),
unit: "GB/s"
};
}
}
function formatBytesTotal(bytes: real): var {
// Handle negative or invalid values
if (bytes < 0 || isNaN(bytes) || !isFinite(bytes)) {
return {
value: 0,
unit: "B"
};
}
if (bytes < 1024) {
return {
value: bytes,
unit: "B"
};
} else if (bytes < 1024 * 1024) {
return {
value: bytes / 1024,
unit: "KB"
};
} else if (bytes < 1024 * 1024 * 1024) {
return {
value: bytes / (1024 * 1024),
unit: "MB"
};
} else {
return {
value: bytes / (1024 * 1024 * 1024),
unit: "GB"
};
}
}
function parseNetDev(content: string): var {
const lines = content.split("\n");
let totalRx = 0;
let totalTx = 0;
for (let i = 2; i < lines.length; i++) {
const line = lines[i].trim();
if (!line)
continue;
const parts = line.split(/\s+/);
if (parts.length < 10)
continue;
const iface = parts[0].replace(":", "");
// Skip loopback interface
if (iface === "lo")
continue;
const rxBytes = parseFloat(parts[1]) || 0;
const txBytes = parseFloat(parts[9]) || 0;
totalRx += rxBytes;
totalTx += txBytes;
}
return {
rx: totalRx,
tx: totalTx
};
}
FileView {
id: netDevFile
path: "/proc/net/dev"
}
Timer {
interval: Config.dashboard.resourceUpdateInterval
running: root.refCount > 0
repeat: true
triggeredOnStart: true
onTriggered: {
netDevFile.reload();
const content = netDevFile.text();
if (!content)
return;
const data = root.parseNetDev(content);
const now = Date.now();
if (!root._initialized) {
root._initialRxBytes = data.rx;
root._initialTxBytes = data.tx;
root._prevRxBytes = data.rx;
root._prevTxBytes = data.tx;
root._prevTimestamp = now;
root._initialized = true;
return;
}
const timeDelta = (now - root._prevTimestamp) / 1000; // seconds
if (timeDelta > 0) {
// Calculate byte deltas
let rxDelta = data.rx - root._prevRxBytes;
let txDelta = data.tx - root._prevTxBytes;
// Handle counter overflow (when counters wrap around from max to 0)
// This happens when counters exceed 32-bit or 64-bit limits
if (rxDelta < 0) {
// Counter wrapped around - assume 64-bit counter
rxDelta += Math.pow(2, 64);
}
if (txDelta < 0) {
txDelta += Math.pow(2, 64);
}
// Calculate speeds
root._downloadSpeed = rxDelta / timeDelta;
root._uploadSpeed = txDelta / timeDelta;
const maxHistory = root.historyLength + 1;
if (root._downloadSpeed >= 0 && isFinite(root._downloadSpeed)) {
let newDownHist = root._downloadHistory.slice();
newDownHist.push(root._downloadSpeed);
if (newDownHist.length > maxHistory) {
newDownHist.shift();
}
root._downloadHistory = newDownHist;
}
if (root._uploadSpeed >= 0 && isFinite(root._uploadSpeed)) {
let newUpHist = root._uploadHistory.slice();
newUpHist.push(root._uploadSpeed);
if (newUpHist.length > maxHistory) {
newUpHist.shift();
}
root._uploadHistory = newUpHist;
}
}
// Calculate totals with overflow handling
let downTotal = data.rx - root._initialRxBytes;
let upTotal = data.tx - root._initialTxBytes;
// Handle counter overflow for totals
if (downTotal < 0) {
downTotal += Math.pow(2, 64);
}
if (upTotal < 0) {
upTotal += Math.pow(2, 64);
}
root._downloadTotal = downTotal;
root._uploadTotal = upTotal;
root._prevRxBytes = data.rx;
root._prevTxBytes = data.tx;
root._prevTimestamp = now;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,338 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.components.misc
import qs.config
import qs.utils
import Caelestia
import Quickshell
import Quickshell.Io
import Quickshell.Services.Notifications
import QtQuick
Singleton {
id: root
property list<Notif> list: []
readonly property list<Notif> notClosed: list.filter(n => !n.closed)
readonly property list<Notif> popups: list.filter(n => n.popup)
property alias dnd: props.dnd
property bool loaded
onDndChanged: {
if (!Config.utilities.toasts.dndChanged)
return;
if (dnd)
Toaster.toast(qsTr("Do not disturb enabled"), qsTr("Popup notifications are now disabled"), "do_not_disturb_on");
else
Toaster.toast(qsTr("Do not disturb disabled"), qsTr("Popup notifications are now enabled"), "do_not_disturb_off");
}
onListChanged: {
if (loaded)
saveTimer.restart();
}
Timer {
id: saveTimer
interval: 1000
onTriggered: storage.setText(JSON.stringify(root.notClosed.map(n => ({
time: n.time,
id: n.id,
summary: n.summary,
body: n.body,
appIcon: n.appIcon,
appName: n.appName,
image: n.image,
expireTimeout: n.expireTimeout,
urgency: n.urgency,
resident: n.resident,
hasActionIcons: n.hasActionIcons,
actions: n.actions
}))))
}
PersistentProperties {
id: props
property bool dnd
reloadableId: "notifs"
}
NotificationServer {
id: server
keepOnReload: false
actionsSupported: true
bodyHyperlinksSupported: true
bodyImagesSupported: true
bodyMarkupSupported: true
imageSupported: true
persistenceSupported: true
onNotification: notif => {
notif.tracked = true;
const comp = notifComp.createObject(root, {
popup: !props.dnd && ![...Visibilities.screens.values()].some(v => v.sidebar),
notification: notif
});
root.list = [comp, ...root.list];
}
}
FileView {
id: storage
path: `${Paths.state}/notifs.json`
onLoaded: {
const data = JSON.parse(text());
for (const notif of data)
root.list.push(notifComp.createObject(root, notif));
root.list.sort((a, b) => b.time - a.time);
root.loaded = true;
}
onLoadFailed: err => {
if (err === FileViewError.FileNotFound) {
root.loaded = true;
setText("[]");
}
}
}
CustomShortcut {
name: "clearNotifs"
description: "Clear all notifications"
onPressed: {
for (const notif of root.list.slice())
notif.close();
}
}
IpcHandler {
target: "notifs"
function clear(): void {
for (const notif of root.list.slice())
notif.close();
}
function isDndEnabled(): bool {
return props.dnd;
}
function toggleDnd(): void {
props.dnd = !props.dnd;
}
function enableDnd(): void {
props.dnd = true;
}
function disableDnd(): void {
props.dnd = false;
}
}
component Notif: QtObject {
id: notif
property bool popup
property bool closed
property var locks: new Set()
property date time: new Date()
readonly property string timeStr: {
const diff = Time.date.getTime() - time.getTime();
const m = Math.floor(diff / 60000);
if (m < 1)
return qsTr("now");
const h = Math.floor(m / 60);
const d = Math.floor(h / 24);
if (d > 0)
return `${d}d`;
if (h > 0)
return `${h}h`;
return `${m}m`;
}
property Notification notification
property string id
property string summary
property string body
property string appIcon
property string appName
property string image
property real expireTimeout: Config.notifs.defaultExpireTimeout
property int urgency: NotificationUrgency.Normal
property bool resident
property bool hasActionIcons
property list<var> actions
readonly property Timer timer: Timer {
running: true
interval: notif.expireTimeout > 0 ? notif.expireTimeout : Config.notifs.defaultExpireTimeout
onTriggered: {
if (Config.notifs.expire)
notif.popup = false;
}
}
readonly property LazyLoader dummyImageLoader: LazyLoader {
active: false
PanelWindow {
implicitWidth: Config.notifs.sizes.image
implicitHeight: Config.notifs.sizes.image
color: "transparent"
mask: Region {}
Image {
function tryCache(): void {
if (status !== Image.Ready || width != Config.notifs.sizes.image || height != Config.notifs.sizes.image)
return;
const cacheKey = notif.appName + notif.summary + notif.id;
let h1 = 0xdeadbeef, h2 = 0x41c6ce57, ch;
for (let i = 0; i < cacheKey.length; i++) {
ch = cacheKey.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
const hash = (h2 >>> 0).toString(16).padStart(8, 0) + (h1 >>> 0).toString(16).padStart(8, 0);
const cache = `${Paths.notifimagecache}/${hash}.png`;
CUtils.saveItem(this, Qt.resolvedUrl(cache), () => {
notif.image = cache;
notif.dummyImageLoader.active = false;
});
}
anchors.fill: parent
source: Qt.resolvedUrl(notif.image)
fillMode: Image.PreserveAspectCrop
cache: false
asynchronous: true
opacity: 0
onStatusChanged: tryCache()
onWidthChanged: tryCache()
onHeightChanged: tryCache()
}
}
}
readonly property Connections conn: Connections {
target: notif.notification
function onClosed(): void {
notif.close();
}
function onSummaryChanged(): void {
notif.summary = notif.notification.summary;
}
function onBodyChanged(): void {
notif.body = notif.notification.body;
}
function onAppIconChanged(): void {
notif.appIcon = notif.notification.appIcon;
}
function onAppNameChanged(): void {
notif.appName = notif.notification.appName;
}
function onImageChanged(): void {
notif.image = notif.notification.image;
if (notif.notification?.image)
notif.dummyImageLoader.active = true;
}
function onExpireTimeoutChanged(): void {
notif.expireTimeout = notif.notification.expireTimeout;
}
function onUrgencyChanged(): void {
notif.urgency = notif.notification.urgency;
}
function onResidentChanged(): void {
notif.resident = notif.notification.resident;
}
function onHasActionIconsChanged(): void {
notif.hasActionIcons = notif.notification.hasActionIcons;
}
function onActionsChanged(): void {
notif.actions = notif.notification.actions.map(a => ({
identifier: a.identifier,
text: a.text,
invoke: () => a.invoke()
}));
}
}
function lock(item: Item): void {
locks.add(item);
}
function unlock(item: Item): void {
locks.delete(item);
if (closed)
close();
}
function close(): void {
closed = true;
if (locks.size === 0 && root.list.includes(this)) {
root.list = root.list.filter(n => n !== this);
notification?.dismiss();
destroy();
}
}
Component.onCompleted: {
if (!notification)
return;
id = notification.id;
summary = notification.summary;
body = notification.body;
appIcon = notification.appIcon;
appName = notification.appName;
image = notification.image;
if (notification?.image)
dummyImageLoader.active = true;
expireTimeout = notification.expireTimeout;
urgency = notification.urgency;
resident = notification.resident;
hasActionIcons = notification.hasActionIcons;
actions = notification.actions.map(a => ({
identifier: a.identifier,
text: a.text,
invoke: () => a.invoke()
}));
}
}
Component {
id: notifComp
Notif {}
}
}

View File

@@ -0,0 +1,126 @@
pragma Singleton
import qs.components.misc
import qs.config
import Quickshell
import Quickshell.Io
import Quickshell.Services.Mpris
import QtQml
import Caelestia
Singleton {
id: root
readonly property list<MprisPlayer> list: Mpris.players.values
readonly property MprisPlayer active: props.manualActive ?? list.find(p => getIdentity(p) === Config.services.defaultPlayer) ?? list[0] ?? null
property alias manualActive: props.manualActive
function getIdentity(player: MprisPlayer): string {
const alias = Config.services.playerAliases.find(a => a.from === player.identity);
return alias?.to ?? player.identity;
}
Connections {
target: active
function onPostTrackChanged() {
if (!Config.utilities.toasts.nowPlaying) {
return;
}
if (active.trackArtist != "" && active.trackTitle != "") {
Toaster.toast(qsTr("Now Playing"), qsTr("%1 - %2").arg(active.trackArtist).arg(active.trackTitle), "music_note");
}
}
}
PersistentProperties {
id: props
property MprisPlayer manualActive
reloadableId: "players"
}
CustomShortcut {
name: "mediaToggle"
description: "Toggle media playback"
onPressed: {
const active = root.active;
if (active && active.canTogglePlaying)
active.togglePlaying();
}
}
CustomShortcut {
name: "mediaPrev"
description: "Previous track"
onPressed: {
const active = root.active;
if (active && active.canGoPrevious)
active.previous();
}
}
CustomShortcut {
name: "mediaNext"
description: "Next track"
onPressed: {
const active = root.active;
if (active && active.canGoNext)
active.next();
}
}
CustomShortcut {
name: "mediaStop"
description: "Stop media playback"
onPressed: root.active?.stop()
}
IpcHandler {
target: "mpris"
function getActive(prop: string): string {
const active = root.active;
return active ? active[prop] ?? "Invalid property" : "No active player";
}
function list(): string {
return root.list.map(p => root.getIdentity(p)).join("\n");
}
function play(): void {
const active = root.active;
if (active?.canPlay)
active.play();
}
function pause(): void {
const active = root.active;
if (active?.canPause)
active.pause();
}
function playPause(): void {
const active = root.active;
if (active?.canTogglePlaying)
active.togglePlaying();
}
function previous(): void {
const active = root.active;
if (active?.canGoPrevious)
active.previous();
}
function next(): void {
const active = root.active;
if (active?.canGoNext)
active.next();
}
function stop(): void {
root.active?.stop();
}
}
}

View File

@@ -0,0 +1,82 @@
pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property alias running: props.running
readonly property alias paused: props.paused
readonly property alias elapsed: props.elapsed
property bool needsStart
property list<string> startArgs
property bool needsStop
property bool needsPause
function start(extraArgs = []): void {
needsStart = true;
startArgs = extraArgs;
checkProc.running = true;
}
function stop(): void {
needsStop = true;
checkProc.running = true;
}
function togglePause(): void {
needsPause = true;
checkProc.running = true;
}
PersistentProperties {
id: props
property bool running: false
property bool paused: false
property real elapsed: 0 // Might get too large for int
reloadableId: "recorder"
}
Process {
id: checkProc
running: true
command: ["pidof", "gpu-screen-recorder"]
onExited: code => {
props.running = code === 0;
if (code === 0) {
if (root.needsStop) {
Quickshell.execDetached(["caelestia", "record"]);
props.running = false;
props.paused = false;
} else if (root.needsPause) {
Quickshell.execDetached(["caelestia", "record", "-p"]);
props.paused = !props.paused;
}
} else if (root.needsStart) {
Quickshell.execDetached(["caelestia", "record", ...root.startArgs]);
props.running = true;
props.paused = false;
props.elapsed = 0;
}
root.needsStart = false;
root.needsStop = false;
root.needsPause = false;
}
}
Connections {
target: Time
// enabled: props.running && !props.paused
function onSecondsChanged(): void {
props.elapsed++;
}
}
}

View File

@@ -0,0 +1,342 @@
pragma Singleton
import qs.config
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
// CPU properties
property string cpuName: ""
property real cpuPerc
property real cpuTemp
// GPU properties
readonly property string gpuType: Config.services.gpuType.toUpperCase() || autoGpuType
property string autoGpuType: "NONE"
property string gpuName: ""
property real gpuPerc
property real gpuTemp
// Memory properties
property real memUsed
property real memTotal
readonly property real memPerc: memTotal > 0 ? memUsed / memTotal : 0
// Storage properties (aggregated)
readonly property real storagePerc: {
let totalUsed = 0;
let totalSize = 0;
for (const disk of disks) {
totalUsed += disk.used;
totalSize += disk.total;
}
return totalSize > 0 ? totalUsed / totalSize : 0;
}
// Individual disks: Array of { mount, used, total, free, perc }
property var disks: []
property real lastCpuIdle
property real lastCpuTotal
property int refCount
function cleanCpuName(name: string): string {
return name.replace(/\(R\)/gi, "").replace(/\(TM\)/gi, "").replace(/CPU/gi, "").replace(/\d+th Gen /gi, "").replace(/\d+nd Gen /gi, "").replace(/\d+rd Gen /gi, "").replace(/\d+st Gen /gi, "").replace(/Core /gi, "").replace(/Processor/gi, "").replace(/\s+/g, " ").trim();
}
function cleanGpuName(name: string): string {
return name.replace(/\(R\)/gi, "").replace(/\(TM\)/gi, "").replace(/Graphics/gi, "").replace(/\s+/g, " ").trim();
}
function formatKib(kib: real): var {
const mib = 1024;
const gib = 1024 ** 2;
const tib = 1024 ** 3;
if (kib >= tib)
return {
value: kib / tib,
unit: "TiB"
};
if (kib >= gib)
return {
value: kib / gib,
unit: "GiB"
};
if (kib >= mib)
return {
value: kib / mib,
unit: "MiB"
};
return {
value: kib,
unit: "KiB"
};
}
Timer {
running: root.refCount > 0
interval: Config.dashboard.resourceUpdateInterval
repeat: true
triggeredOnStart: true
onTriggered: {
stat.reload();
meminfo.reload();
storage.running = true;
gpuUsage.running = true;
sensors.running = true;
}
}
// One-time CPU info detection (name)
FileView {
id: cpuinfoInit
path: "/proc/cpuinfo"
onLoaded: {
const nameMatch = text().match(/model name\s*:\s*(.+)/);
if (nameMatch)
root.cpuName = root.cleanCpuName(nameMatch[1]);
}
}
FileView {
id: stat
path: "/proc/stat"
onLoaded: {
const data = text().match(/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/);
if (data) {
const stats = data.slice(1).map(n => parseInt(n, 10));
const total = stats.reduce((a, b) => a + b, 0);
const idle = stats[3] + (stats[4] ?? 0);
const totalDiff = total - root.lastCpuTotal;
const idleDiff = idle - root.lastCpuIdle;
root.cpuPerc = totalDiff > 0 ? (1 - idleDiff / totalDiff) : 0;
root.lastCpuTotal = total;
root.lastCpuIdle = idle;
}
}
}
FileView {
id: meminfo
path: "/proc/meminfo"
onLoaded: {
const data = text();
root.memTotal = parseInt(data.match(/MemTotal: *(\d+)/)[1], 10) || 1;
root.memUsed = (root.memTotal - parseInt(data.match(/MemAvailable: *(\d+)/)[1], 10)) || 0;
}
}
Process {
id: storage
// Get physical disks with aggregated usage from their partitions
// lsblk outputs: NAME SIZE TYPE FSUSED FSSIZE in bytes
command: ["lsblk", "-b", "-o", "NAME,SIZE,TYPE,FSUSED,FSSIZE", "-P"]
stdout: StdioCollector {
onStreamFinished: {
const diskMap = {}; // Map disk name -> { name, totalSize, used, fsTotal }
const lines = text.trim().split("\n");
for (const line of lines) {
if (line.trim() === "")
continue;
// Parse KEY="VALUE" format
const nameMatch = line.match(/NAME="([^"]+)"/);
const sizeMatch = line.match(/SIZE="([^"]+)"/);
const typeMatch = line.match(/TYPE="([^"]+)"/);
const fsusedMatch = line.match(/FSUSED="([^"]*)"/);
const fssizeMatch = line.match(/FSSIZE="([^"]*)"/);
if (!nameMatch || !typeMatch)
continue;
const name = nameMatch[1];
const type = typeMatch[1];
const size = parseInt(sizeMatch?.[1] || "0", 10);
const fsused = parseInt(fsusedMatch?.[1] || "0", 10);
const fssize = parseInt(fssizeMatch?.[1] || "0", 10);
if (type === "disk") {
// Skip zram (swap) devices
if (name.startsWith("zram"))
continue;
// Initialize disk entry
if (!diskMap[name]) {
diskMap[name] = {
name: name,
totalSize: size,
used: 0,
fsTotal: 0
};
}
} else if (type === "part") {
// Find parent disk (remove trailing numbers/p+numbers)
let parentDisk = name.replace(/p?\d+$/, "");
// For nvme devices like nvme0n1p1, parent is nvme0n1
if (name.match(/nvme\d+n\d+p\d+/))
parentDisk = name.replace(/p\d+$/, "");
// Aggregate partition usage to parent disk
if (diskMap[parentDisk]) {
diskMap[parentDisk].used += fsused;
diskMap[parentDisk].fsTotal += fssize;
}
}
}
// Convert map to sorted array
const diskList = [];
let totalUsed = 0;
let totalSize = 0;
for (const diskName of Object.keys(diskMap).sort()) {
const disk = diskMap[diskName];
// Use filesystem total if available, otherwise use disk size
const total = disk.fsTotal > 0 ? disk.fsTotal : disk.totalSize;
const used = disk.used;
const perc = total > 0 ? used / total : 0;
// Convert bytes to KiB for consistency with formatKib
diskList.push({
mount: disk.name // Using 'mount' property for compatibility
,
used: used / 1024,
total: total / 1024,
free: (total - used) / 1024,
perc: perc
});
totalUsed += used;
totalSize += total;
}
root.disks = diskList;
}
}
}
// GPU name detection (one-time)
Process {
id: gpuNameDetect
running: true
command: ["sh", "-c", "nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null || glxinfo -B 2>/dev/null | grep 'Device:' | cut -d':' -f2 | cut -d'(' -f1 || lspci 2>/dev/null | grep -i 'vga\\|3d controller\\|display' | head -1"]
stdout: StdioCollector {
onStreamFinished: {
const output = text.trim();
if (!output)
return;
// Check if it's from nvidia-smi (clean GPU name)
if (output.toLowerCase().includes("nvidia") || output.toLowerCase().includes("geforce") || output.toLowerCase().includes("rtx") || output.toLowerCase().includes("gtx")) {
root.gpuName = root.cleanGpuName(output);
} else if (output.toLowerCase().includes("rx")) {
root.gpuName = root.cleanGpuName(output);
} else {
// Parse lspci output: extract name from brackets or after colon
// Handles cases like [AMD/ATI] Navi 21 [Radeon RX 6800/6800 XT / 6900 XT] (rev c0)
const bracketMatch = output.match(/\[([^\]]+)\][^\[]*$/);
if (bracketMatch) {
root.gpuName = root.cleanGpuName(bracketMatch[1]);
} else {
const colonMatch = output.match(/:\s*(.+)/);
if (colonMatch)
root.gpuName = root.cleanGpuName(colonMatch[1]);
}
}
}
}
}
Process {
id: gpuTypeCheck
running: !Config.services.gpuType
command: ["sh", "-c", "if command -v nvidia-smi &>/dev/null && nvidia-smi -L &>/dev/null; then echo NVIDIA; elif ls /sys/class/drm/card*/device/gpu_busy_percent 2>/dev/null | grep -q .; then echo GENERIC; else echo NONE; fi"]
stdout: StdioCollector {
onStreamFinished: root.autoGpuType = text.trim()
}
}
Process {
id: gpuUsage
command: root.gpuType === "GENERIC" ? ["sh", "-c", "cat /sys/class/drm/card*/device/gpu_busy_percent"] : root.gpuType === "NVIDIA" ? ["nvidia-smi", "--query-gpu=utilization.gpu,temperature.gpu", "--format=csv,noheader,nounits"] : ["echo"]
stdout: StdioCollector {
onStreamFinished: {
if (root.gpuType === "GENERIC") {
const percs = text.trim().split("\n");
const sum = percs.reduce((acc, d) => acc + parseInt(d, 10), 0);
root.gpuPerc = sum / percs.length / 100;
} else if (root.gpuType === "NVIDIA") {
const [usage, temp] = text.trim().split(",");
root.gpuPerc = parseInt(usage, 10) / 100;
root.gpuTemp = parseInt(temp, 10);
} else {
root.gpuPerc = 0;
root.gpuTemp = 0;
}
}
}
}
Process {
id: sensors
command: ["sensors"]
environment: ({
LANG: "C.UTF-8",
LC_ALL: "C.UTF-8"
})
stdout: StdioCollector {
onStreamFinished: {
let cpuTemp = text.match(/(?:Package id [0-9]+|Tdie):\s+((\+|-)[0-9.]+)(°| )C/);
if (!cpuTemp)
// If AMD Tdie pattern failed, try fallback on Tctl
cpuTemp = text.match(/Tctl:\s+((\+|-)[0-9.]+)(°| )C/);
if (cpuTemp)
root.cpuTemp = parseFloat(cpuTemp[1]);
if (root.gpuType !== "GENERIC")
return;
let eligible = false;
let sum = 0;
let count = 0;
for (const line of text.trim().split("\n")) {
if (line === "Adapter: PCI adapter")
eligible = true;
else if (line === "")
eligible = false;
else if (eligible) {
let match = line.match(/^(temp[0-9]+|GPU core|edge)+:\s+\+([0-9]+\.[0-9]+)(°| )C/);
if (!match)
// Fall back to junction/mem if GPU doesn't have edge temp (for AMD GPUs)
match = line.match(/^(junction|mem)+:\s+\+([0-9]+\.[0-9]+)(°| )C/);
if (match) {
sum += parseFloat(match[2]);
count++;
}
}
}
root.gpuTemp = count > 0 ? sum / count : 0;
}
}
}
}

View File

@@ -0,0 +1,28 @@
pragma Singleton
import qs.config
import Quickshell
import QtQuick
Singleton {
property alias enabled: clock.enabled
readonly property date date: clock.date
readonly property int hours: clock.hours
readonly property int minutes: clock.minutes
readonly property int seconds: clock.seconds
readonly property string timeStr: format(Config.services.useTwelveHourClock ? "hh:mm:A" : "hh:mm")
readonly property list<string> timeComponents: timeStr.split(":")
readonly property string hourStr: timeComponents[0] ?? ""
readonly property string minuteStr: timeComponents[1] ?? ""
readonly property string amPmStr: timeComponents[2] ?? ""
function format(fmt: string): string {
return Qt.formatDateTime(clock.date, fmt);
}
SystemClock {
id: clock
precision: SystemClock.Seconds
}
}

View File

@@ -0,0 +1,179 @@
pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import Caelestia
Singleton {
id: root
property bool connected: false
readonly property bool connecting: connectProc.running || disconnectProc.running
readonly property bool enabled: Config.utilities.vpn.provider.some(p => typeof p === "object" ? (p.enabled === true) : false)
readonly property var providerInput: {
const enabledProvider = Config.utilities.vpn.provider.find(p => typeof p === "object" ? (p.enabled === true) : false);
return enabledProvider || "wireguard";
}
readonly property bool isCustomProvider: typeof providerInput === "object"
readonly property string providerName: isCustomProvider ? (providerInput.name || "custom") : String(providerInput)
readonly property string interfaceName: isCustomProvider ? (providerInput.interface || "") : ""
readonly property var currentConfig: {
const name = providerName;
const iface = interfaceName;
const defaults = getBuiltinDefaults(name, iface);
if (isCustomProvider) {
const custom = providerInput;
return {
connectCmd: custom.connectCmd || defaults.connectCmd,
disconnectCmd: custom.disconnectCmd || defaults.disconnectCmd,
interface: custom.interface || defaults.interface,
displayName: custom.displayName || defaults.displayName
};
}
return defaults;
}
function getBuiltinDefaults(name, iface) {
const builtins = {
"wireguard": {
connectCmd: ["pkexec", "wg-quick", "up", iface],
disconnectCmd: ["pkexec", "wg-quick", "down", iface],
interface: iface,
displayName: iface
},
"warp": {
connectCmd: ["warp-cli", "connect"],
disconnectCmd: ["warp-cli", "disconnect"],
interface: "CloudflareWARP",
displayName: "Warp"
},
"netbird": {
connectCmd: ["netbird", "up"],
disconnectCmd: ["netbird", "down"],
interface: "wt0",
displayName: "NetBird"
},
"tailscale": {
connectCmd: ["tailscale", "up"],
disconnectCmd: ["tailscale", "down"],
interface: "tailscale0",
displayName: "Tailscale"
}
};
return builtins[name] || {
connectCmd: [name, "up"],
disconnectCmd: [name, "down"],
interface: iface || name,
displayName: name
};
}
function connect(): void {
if (!connected && !connecting && root.currentConfig && root.currentConfig.connectCmd) {
connectProc.exec(root.currentConfig.connectCmd);
}
}
function disconnect(): void {
if (connected && !connecting && root.currentConfig && root.currentConfig.disconnectCmd) {
disconnectProc.exec(root.currentConfig.disconnectCmd);
}
}
function toggle(): void {
if (connected) {
disconnect();
} else {
connect();
}
}
function checkStatus(): void {
if (root.enabled) {
statusProc.running = true;
}
}
onConnectedChanged: {
if (!Config.utilities.toasts.vpnChanged)
return;
const displayName = root.currentConfig ? (root.currentConfig.displayName || "VPN") : "VPN";
if (connected) {
Toaster.toast(qsTr("VPN connected"), qsTr("Connected to %1").arg(displayName), "vpn_key");
} else {
Toaster.toast(qsTr("VPN disconnected"), qsTr("Disconnected from %1").arg(displayName), "vpn_key_off");
}
}
Component.onCompleted: root.enabled && statusCheckTimer.start()
Process {
id: nmMonitor
running: root.enabled
command: ["nmcli", "monitor"]
stdout: SplitParser {
onRead: statusCheckTimer.restart()
}
}
Process {
id: statusProc
command: ["ip", "link", "show"]
environment: ({
LANG: "C.UTF-8",
LC_ALL: "C.UTF-8"
})
stdout: StdioCollector {
onStreamFinished: {
const iface = root.currentConfig ? root.currentConfig.interface : "";
root.connected = iface && text.includes(iface + ":");
}
}
}
Process {
id: connectProc
onExited: statusCheckTimer.start()
stderr: StdioCollector {
onStreamFinished: {
const error = text.trim();
if (error && !error.includes("[#]") && !error.includes("already exists")) {
console.warn("VPN connection error:", error);
} else if (error.includes("already exists")) {
root.connected = true;
}
}
}
}
Process {
id: disconnectProc
onExited: statusCheckTimer.start()
stderr: StdioCollector {
onStreamFinished: {
const error = text.trim();
if (error && !error.includes("[#]")) {
console.warn("VPN disconnection error:", error);
}
}
}
}
Timer {
id: statusCheckTimer
interval: 500
onTriggered: root.checkStatus()
}
}

View File

@@ -0,0 +1,16 @@
pragma Singleton
import Quickshell
Singleton {
property var screens: new Map()
property var bars: new Map()
function load(screen: ShellScreen, visibilities: var): void {
screens.set(Hypr.monitorFor(screen), visibilities);
}
function getForActive(): PersistentProperties {
return screens.get(Hypr.focusedMonitor);
}
}

View File

@@ -0,0 +1,93 @@
pragma Singleton
import qs.config
import qs.utils
import Caelestia.Models
import Quickshell
import Quickshell.Io
import QtQuick
Searcher {
id: root
readonly property string currentNamePath: `${Paths.state}/wallpaper/path.txt`
readonly property list<string> smartArg: Config.services.smartScheme ? [] : ["--no-smart"]
property bool showPreview: false
readonly property string current: showPreview ? previewPath : actualCurrent
property string previewPath
property string actualCurrent
property bool previewColourLock
function setWallpaper(path: string): void {
actualCurrent = path;
Quickshell.execDetached(["caelestia", "wallpaper", "-f", path, ...smartArg]);
}
function preview(path: string): void {
previewPath = path;
showPreview = true;
if (Colours.scheme === "dynamic")
getPreviewColoursProc.running = true;
}
function stopPreview(): void {
showPreview = false;
if (!previewColourLock)
Colours.showPreview = false;
}
list: wallpapers.entries
key: "relativePath"
useFuzzy: Config.launcher.useFuzzy.wallpapers
extraOpts: useFuzzy ? ({}) : ({
forward: false
})
IpcHandler {
target: "wallpaper"
function get(): string {
return root.actualCurrent;
}
function set(path: string): void {
root.setWallpaper(path);
}
function list(): string {
return root.list.map(w => w.path).join("\n");
}
}
FileView {
path: root.currentNamePath
watchChanges: true
onFileChanged: reload()
onLoaded: {
root.actualCurrent = text().trim();
root.previewColourLock = false;
}
}
FileSystemModel {
id: wallpapers
recursive: true
path: Paths.wallsdir
filter: FileSystemModel.Images
}
Process {
id: getPreviewColoursProc
command: ["caelestia", "wallpaper", "-p", root.previewPath, ...root.smartArg]
stdout: StdioCollector {
onStreamFinished: {
Colours.load(text, true);
Colours.showPreview = true;
}
}
}
}

View File

@@ -0,0 +1,206 @@
pragma Singleton
import qs.config
import qs.utils
import Caelestia
import Quickshell
import QtQuick
Singleton {
id: root
property string city
property string loc
property var cc
property list<var> forecast
property list<var> hourlyForecast
readonly property string icon: cc ? Icons.getWeatherIcon(cc.weatherCode) : "cloud_alert"
readonly property string description: cc?.weatherDesc ?? qsTr("No weather")
readonly property string temp: Config.services.useFahrenheit ? `${cc?.tempF ?? 0}°F` : `${cc?.tempC ?? 0}°C`
readonly property string feelsLike: Config.services.useFahrenheit ? `${cc?.feelsLikeF ?? 0}°F` : `${cc?.feelsLikeC ?? 0}°C`
readonly property int humidity: cc?.humidity ?? 0
readonly property real windSpeed: cc?.windSpeed ?? 0
readonly property string sunrise: cc ? Qt.formatDateTime(new Date(cc.sunrise), Config.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--"
readonly property string sunset: cc ? Qt.formatDateTime(new Date(cc.sunset), Config.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--"
readonly property var cachedCities: new Map()
function reload(): void {
const configLocation = Config.services.weatherLocation;
if (configLocation) {
if (configLocation.indexOf(",") !== -1 && !isNaN(parseFloat(configLocation.split(",")[0]))) {
loc = configLocation;
fetchCityFromCoords(configLocation);
} else {
fetchCoordsFromCity(configLocation);
}
} else if (!loc || timer.elapsed() > 900) {
Requests.get("https://ipinfo.io/json", text => {
const response = JSON.parse(text);
if (response.loc) {
loc = response.loc;
city = response.city ?? "";
timer.restart();
}
});
}
}
function fetchCityFromCoords(coords: string): void {
if (cachedCities.has(coords)) {
city = cachedCities.get(coords);
return;
}
const [lat, lon] = coords.split(",");
const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=geocodejson`;
Requests.get(url, text => {
const geo = JSON.parse(text).features?.[0]?.properties.geocoding;
if (geo) {
const geoCity = geo.type === "city" ? geo.name : geo.city;
city = geoCity;
cachedCities.set(coords, geoCity);
} else {
city = "Unknown City";
}
});
}
function fetchCoordsFromCity(cityName: string): void {
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(cityName)}&count=1&language=en&format=json`;
Requests.get(url, text => {
const json = JSON.parse(text);
if (json.results && json.results.length > 0) {
const result = json.results[0];
loc = result.latitude + "," + result.longitude;
city = result.name;
} else {
loc = "";
reload();
}
});
}
function fetchWeatherData(): void {
const url = getWeatherUrl();
if (url === "")
return;
Requests.get(url, text => {
const json = JSON.parse(text);
if (!json.current || !json.daily)
return;
cc = {
weatherCode: json.current.weather_code,
weatherDesc: getWeatherCondition(json.current.weather_code),
tempC: Math.round(json.current.temperature_2m),
tempF: Math.round(toFahrenheit(json.current.temperature_2m)),
feelsLikeC: Math.round(json.current.apparent_temperature),
feelsLikeF: Math.round(toFahrenheit(json.current.apparent_temperature)),
humidity: json.current.relative_humidity_2m,
windSpeed: json.current.wind_speed_10m,
isDay: json.current.is_day,
sunrise: json.daily.sunrise[0],
sunset: json.daily.sunset[0]
};
const forecastList = [];
for (let i = 0; i < json.daily.time.length; i++)
forecastList.push({
date: json.daily.time[i],
maxTempC: Math.round(json.daily.temperature_2m_max[i]),
maxTempF: Math.round(toFahrenheit(json.daily.temperature_2m_max[i])),
minTempC: Math.round(json.daily.temperature_2m_min[i]),
minTempF: Math.round(toFahrenheit(json.daily.temperature_2m_min[i])),
weatherCode: json.daily.weather_code[i],
icon: Icons.getWeatherIcon(json.daily.weather_code[i])
});
forecast = forecastList;
const hourlyList = [];
const now = new Date();
for (let i = 0; i < json.hourly.time.length; i++) {
const time = new Date(json.hourly.time[i]);
if (time < now)
continue;
hourlyList.push({
timestamp: json.hourly.time[i],
hour: time.getHours(),
tempC: Math.round(json.hourly.temperature_2m[i]),
tempF: Math.round(toFahrenheit(json.hourly.temperature_2m[i])),
weatherCode: json.hourly.weather_code[i],
icon: Icons.getWeatherIcon(json.hourly.weather_code[i])
});
}
hourlyForecast = hourlyList;
});
}
function toFahrenheit(celcius: real): real {
return celcius * 9 / 5 + 32;
}
function getWeatherUrl(): string {
if (!loc || loc.indexOf(",") === -1)
return "";
const [lat, lon] = loc.split(",");
const baseUrl = "https://api.open-meteo.com/v1/forecast";
const params = ["latitude=" + lat, "longitude=" + lon, "hourly=weather_code,temperature_2m", "daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset", "current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10m", "timezone=auto", "forecast_days=7"];
return baseUrl + "?" + params.join("&");
}
function getWeatherCondition(code: string): string {
const conditions = {
"0": "Clear",
"1": "Clear",
"2": "Partly cloudy",
"3": "Overcast",
"45": "Fog",
"48": "Fog",
"51": "Drizzle",
"53": "Drizzle",
"55": "Drizzle",
"56": "Freezing drizzle",
"57": "Freezing drizzle",
"61": "Light rain",
"63": "Rain",
"65": "Heavy rain",
"66": "Light rain",
"67": "Heavy rain",
"71": "Light snow",
"73": "Snow",
"75": "Heavy snow",
"77": "Snow",
"80": "Light rain",
"81": "Rain",
"82": "Heavy rain",
"85": "Light snow showers",
"86": "Heavy snow showers",
"95": "Thunderstorm",
"96": "Thunderstorm with hail",
"99": "Thunderstorm with hail"
};
return conditions[code] || "Unknown";
}
onLocChanged: fetchWeatherData()
// Refresh current location hourly
Timer {
interval: 3600000 // 1 hour
running: true
repeat: true
onTriggered: fetchWeatherData()
}
ElapsedTimer {
id: timer
}
}