better structure

This commit is contained in:
2025-10-19 00:14:19 +02:00
parent 057afc086e
commit 8733656ed9
630 changed files with 81 additions and 137 deletions

View File

@@ -0,0 +1,146 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import qs.Utils
Singleton {
id: root
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)
}
}
return acc
}, {
"sources": [],
"sinks": []
})
readonly property PwNode sink: Pipewire.defaultAudioSink
readonly property PwNode source: Pipewire.defaultAudioSource
readonly property list<PwNode> sinks: nodes.sinks
readonly property list<PwNode> sources: nodes.sources
// Volume [0..1] is readonly from outside
readonly property alias volume: root._volume
property real _volume: sink?.audio?.volume ?? 0
readonly property alias muted: root._muted
property bool _muted: !!sink?.audio?.muted
// Input volume [0..1] is readonly from outside
readonly property alias inputVolume: root._inputVolume
property real _inputVolume: source?.audio?.volume ?? 0
readonly property alias inputMuted: root._inputMuted
property bool _inputMuted: !!source?.audio?.muted
readonly property real stepVolume: 5 / 100.0
PwObjectTracker {
objects: [...root.sinks, ...root.sources]
}
Connections {
target: sink?.audio ? sink?.audio : null
function onVolumeChanged() {
var vol = (sink?.audio.volume ?? 0)
if (isNaN(vol)) {
vol = 0
}
root._volume = vol
}
function onMutedChanged() {
root._muted = (sink?.audio.muted ?? true)
Logger.log("AudioService", "OnMuteChanged:", root._muted)
}
}
Connections {
target: source?.audio ? source?.audio : null
function onVolumeChanged() {
var vol = (source?.audio.volume ?? 0)
if (isNaN(vol)) {
vol = 0
}
root._inputVolume = vol
}
function onMutedChanged() {
root._inputMuted = (source?.audio.muted ?? true)
Logger.log("AudioService", "OnInputMuteChanged:", root._inputMuted)
}
}
function increaseVolume() {
setVolume(volume + stepVolume)
}
function decreaseVolume() {
setVolume(volume - stepVolume)
}
function setVolume(newVolume: real) {
if (sink?.ready && sink?.audio) {
// Clamp it accordingly
sink.audio.muted = false
sink.audio.volume = Math.max(0, Math.min(1.0, newVolume))
} else {
Logger.warn("AudioService", "No sink available")
}
}
function setOutputMuted(muted: bool) {
if (sink?.ready && sink?.audio) {
sink.audio.muted = muted
} else {
Logger.warn("AudioService", "No sink available")
}
}
function setInputVolume(newVolume: real) {
if (source?.ready && source?.audio) {
// Clamp it accordingly
source.audio.muted = false
source.audio.volume = Math.max(0, Math.min(1.0, newVolume))
} else {
Logger.warn("AudioService", "No source available")
}
}
function setInputMuted(muted: bool) {
if (source?.ready && source?.audio) {
source.audio.muted = muted
} else {
Logger.warn("AudioService", "No source available")
}
}
function setAudioSink(newSink: PwNode): void {
Pipewire.preferredDefaultAudioSink = newSink
// Volume is changed by the sink change
root._volume = newSink?.audio?.volume ?? 0
root._muted = !!newSink?.audio?.muted
}
function setAudioSource(newSource: PwNode): void {
Pipewire.preferredDefaultAudioSource = newSource
// Volume is changed by the source change
root._inputVolume = newSource?.audio?.volume ?? 0
root._inputMuted = !!newSource?.audio?.muted
}
function toggleMute() {
setOutputMuted(!muted)
}
}

View File

@@ -0,0 +1,308 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Utils
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)
}
// Signal emitted when a specific monitor's brightness changes, includes monitor context
signal monitorBrightnessChanged(var monitor, real newBrightness)
function getAvailableMethods(): list<string> {
var methods = []
if (monitors.some(m => m.isDdc))
methods.push("ddcutil")
if (monitors.some(m => !m.isDdc))
methods.push("internal")
if (appleDisplayPresent)
methods.push("apple")
return methods
}
// Global helpers for IPC and shortcuts
function increaseBrightness(): void {
monitors.forEach(m => m.increaseBrightness())
}
function decreaseBrightness(): void {
monitors.forEach(m => m.decreaseBrightness())
}
function getDetectedDisplays(): list<var> {
return detectedDisplays
}
reloadableId: "brightness"
Component.onCompleted: {
Logger.log("Brightness", "Service started")
}
onMonitorsChanged: {
ddcMonitors = []
ddcProc.running = true
}
Variants {
id: variants
model: Quickshell.screens
Monitor {}
}
// Check for Apple Display support
Process {
running: true
command: ["sh", "-c", "which asdbctl >/dev/null 2>&1 && asdbctl get || echo ''"]
stdout: StdioCollector {
onStreamFinished: root.appleDisplayPresent = text.trim().length > 0
}
}
// Detect DDC monitors
Process {
id: ddcProc
property list<var> ddcMonitors: []
command: ["ddcutil", "detect", "--sleep-multiplier=0.5"]
stdout: StdioCollector {
onStreamFinished: {
var displays = text.trim().split("\n\n")
ddcProc.ddcMonitors = displays.map(d => {
var ddcModelMatch = d.match(/(This monitor does not support DDC\/CI|Invalid display)/)
var modelMatch = d.match(/Model:\s*(.*)/)
var busMatch = d.match(/I2C bus:[ ]*\/dev\/i2c-([0-9]+)/)
var ddcModel = ddcModelMatch ? ddcModelMatch.length > 0 : false
var model = modelMatch ? modelMatch[1] : "Unknown"
var bus = busMatch ? busMatch[1] : "Unknown"
Logger.log("Brigthness", "Detected DDC Monitor:", model, "on bus", bus, "is DDC:", !ddcModel)
return {
"model": model,
"busNum": bus,
"isDdc": !ddcModel
}
})
root.ddcMonitors = ddcProc.ddcMonitors.filter(m => m.isDdc)
}
}
}
component Monitor: QtObject {
id: monitor
required property ShellScreen modelData
readonly property bool isDdc: root.ddcMonitors.some(m => m.model === modelData.model)
readonly property string busNum: root.ddcMonitors.find(m => m.model === modelData.model)?.busNum ?? ""
readonly property bool isAppleDisplay: root.appleDisplayPresent && modelData.model.startsWith("StudioDisplay")
readonly property string method: isAppleDisplay ? "apple" : (isDdc ? "ddcutil" : "internal")
property real brightness
property real lastBrightness: 0
property real queuedBrightness: NaN
// For internal displays - store the backlight device path
property string backlightDevice: ""
property string brightnessPath: ""
property string maxBrightnessPath: ""
property int maxBrightness: 100
property bool ignoreNextChange: false
// Signal for brightness changes
signal brightnessUpdated(real newBrightness)
// Execute a system command to get the current brightness value directly
readonly property Process refreshProc: Process {
stdout: StdioCollector {
onStreamFinished: {
var dataText = text.trim()
if (dataText === "") {
return
}
var lines = dataText.split("\n")
if (lines.length >= 2) {
var current = parseInt(lines[0].trim())
var max = parseInt(lines[1].trim())
if (!isNaN(current) && !isNaN(max) && max > 0) {
var newBrightness = current / max
// Only update if it's actually different (avoid feedback loops)
if (Math.abs(newBrightness - monitor.brightness) > 0.01) {
// Update internal value to match system state
monitor.brightness = newBrightness
monitor.brightnessUpdated(monitor.brightness)
root.monitorBrightnessChanged(monitor, monitor.brightness)
}
}
}
}
}
}
// Function to actively refresh the brightness from system
function refreshBrightnessFromSystem() {
if (!monitor.isDdc && !monitor.isAppleDisplay) {
// For internal displays, query the system directly
refreshProc.command = ["sh", "-c", "cat " + monitor.brightnessPath + " && " + "cat " + monitor.maxBrightnessPath]
refreshProc.running = true
} else if (monitor.isDdc) {
// For DDC displays, get the current value
refreshProc.command = ["ddcutil", "-b", monitor.busNum, "getvcp", "10", "--brief"]
refreshProc.running = true
} else if (monitor.isAppleDisplay) {
// For Apple displays, get the current value
refreshProc.command = ["asdbctl", "get"]
refreshProc.running = true
}
}
// FileView to watch for external brightness changes (internal displays only)
readonly property FileView brightnessWatcher: FileView {
id: brightnessWatcher
// Only set path for internal displays with a valid brightness path
path: (!monitor.isDdc && !monitor.isAppleDisplay && monitor.brightnessPath !== "") ? monitor.brightnessPath : ""
watchChanges: path !== ""
onFileChanged: {
// When a file change is detected, actively refresh from system
// to ensure we get the most up-to-date value
Qt.callLater(() => {
monitor.refreshBrightnessFromSystem()
})
}
}
// Initialize brightness
readonly property Process initProc: Process {
stdout: StdioCollector {
onStreamFinished: {
var dataText = text.trim()
if (dataText === "") {
return
}
if (monitor.isAppleDisplay) {
var val = parseInt(dataText)
if (!isNaN(val)) {
monitor.brightness = val / 101
Logger.log("Brightness", "Apple display brightness:", monitor.brightness)
}
} else if (monitor.isDdc) {
var parts = dataText.split(" ")
if (parts.length >= 4) {
var current = parseInt(parts[3])
var max = parseInt(parts[4])
if (!isNaN(current) && !isNaN(max) && max > 0) {
monitor.brightness = current / max
Logger.log("Brightness", "DDC brightness:", current + "/" + max + " =", monitor.brightness)
}
}
} else {
// Internal backlight - parse the response which includes device path
var lines = dataText.split("\n")
if (lines.length >= 3) {
monitor.backlightDevice = lines[0]
monitor.brightnessPath = monitor.backlightDevice + "/brightness"
monitor.maxBrightnessPath = monitor.backlightDevice + "/max_brightness"
var current = parseInt(lines[1])
var max = parseInt(lines[2])
if (!isNaN(current) && !isNaN(max) && max > 0) {
monitor.maxBrightness = max
monitor.brightness = current / max
Logger.log("Brightness", "Internal brightness:", current + "/" + max + " =", monitor.brightness)
Logger.log("Brightness", "Using backlight device:", monitor.backlightDevice)
}
}
}
// Always update
monitor.brightnessUpdated(monitor.brightness)
root.monitorBrightnessChanged(monitor, monitor.brightness)
}
}
}
readonly property real stepSize: 5.0 / 100.0
// Timer for debouncing rapid changes
readonly property Timer timer: Timer {
interval: 100
onTriggered: {
if (!isNaN(monitor.queuedBrightness)) {
monitor.setBrightness(monitor.queuedBrightness)
monitor.queuedBrightness = NaN
}
}
}
function setBrightnessDebounced(value: real): void {
monitor.queuedBrightness = value
timer.start()
}
function increaseBrightness(): void {
const value = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness
setBrightnessDebounced(value + stepSize)
}
function decreaseBrightness(): void {
const value = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness
setBrightnessDebounced(value - stepSize)
}
function setBrightness(value: real): void {
value = Math.max(0, Math.min(1, value))
var rounded = Math.round(value * 100)
if (timer.running) {
monitor.queuedBrightness = value
return
}
// Update internal value and trigger UI feedback
monitor.brightness = value
monitor.brightnessUpdated(value)
root.monitorBrightnessChanged(monitor, monitor.brightness)
if (isAppleDisplay) {
monitor.ignoreNextChange = true
Quickshell.execDetached(["asdbctl", "set", rounded])
} else if (isDdc) {
monitor.ignoreNextChange = true
Quickshell.execDetached(["ddcutil", "-b", busNum, "setvcp", "10", rounded])
} else {
monitor.ignoreNextChange = true
Quickshell.execDetached(["set-brightness", 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 {
// Internal backlight - find the first available backlight device and get its info
// This now returns: device_path, current_brightness, max_brightness (on separate lines)
initProc.command = ["sh", "-c", "for dev in /sys/class/backlight/*; do " + " if [ -f \"$dev/brightness\" ] && [ -f \"$dev/max_brightness\" ]; then " + " echo \"$dev\"; " + " cat \"$dev/brightness\"; " + " cat \"$dev/max_brightness\"; " + " break; " + " fi; " + "done"]
}
initProc.running = true
}
onBusNumChanged: initBrightness()
Component.onCompleted: initBrightness()
}
}

View File

@@ -0,0 +1,32 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Utils
pragma Singleton
Singleton {
id: root
property string cacheDir: Quickshell.env("HOME") + "/.cache/quickshell/"
property string recordingDir: Quickshell.env("HOME") + "/Videos/recordings/"
property var cacheFiles: ["Location.json", "Ip.json", "Notifications.json", "LyricsOffset.txt"]
property bool loaded: false
property string locationCacheFile: cacheDir + "Location.json"
property string ipCacheFile: cacheDir + "Ip.json"
property string notificationsCacheFile: cacheDir + "Notifications.json"
property string lyricsOffsetCacheFile: cacheDir + "LyricsOffset.txt"
Process {
id: process
running: true
command: ["sh", "-c", `mkdir -p ${cacheDir} && mkdir -p ${recordingDir} && touch ${cacheDir + cacheFiles.join(` && touch ${cacheDir}`)}`]
onExited: (code, status) => {
if (code === 0)
root.loaded = true;
else
Logger.error("CacheService", `Failed to create cache files: ${command.join(" ")}`);
}
}
}

View File

@@ -0,0 +1,143 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
import qs.Utils
pragma Singleton
Singleton {
id: root
property string reason: "Application request"
property bool isInhibited: false
property var activeInhibitors: []
// Different inhibitor strategies
property string strategy: "systemd"
// Auto-detect the best strategy
function detectStrategy() {
if (strategy === "auto") {
// Check if systemd-inhibit is available
try {
var systemdResult = Quickshell.execDetached(["which", "systemd-inhibit"]);
strategy = "systemd";
return ;
} catch (e) {
}
try {
var waylandResult = Quickshell.execDetached(["which", "wayhibitor"]);
strategy = "wayland";
return ;
} catch (e) {
}
strategy = "systemd"; // Fallback to systemd even if not detected
}
}
// Add an inhibitor
function addInhibitor(id, reason = "Application request") {
if (activeInhibitors.includes(id))
return false;
activeInhibitors.push(id);
updateInhibition(reason);
return true;
}
// Remove an inhibitor
function removeInhibitor(id) {
const index = activeInhibitors.indexOf(id);
if (index === -1) {
console.log("Inhibitor not found:", id);
return false;
}
activeInhibitors.splice(index, 1);
updateInhibition();
return true;
}
// Update the actual system inhibition
function updateInhibition(newReason = reason) {
const shouldInhibit = activeInhibitors.length > 0;
if (shouldInhibit === isInhibited)
return ;
// No change needed
if (shouldInhibit)
startInhibition(newReason);
else
stopInhibition();
}
// Start system inhibition
function startInhibition(newReason) {
reason = newReason;
if (strategy === "systemd")
startSystemdInhibition();
else if (strategy === "wayland")
startWaylandInhibition();
else
return ;
isInhibited = true;
}
// Stop system inhibition
function stopInhibition() {
if (!isInhibited)
return ;
// SIGTERM
if (inhibitorProcess.running)
inhibitorProcess.signal(15);
isInhibited = false;
}
// Systemd inhibition using systemd-inhibit
function startSystemdInhibition() {
inhibitorProcess.command = ["systemd-inhibit", "--what=idle", "--why=" + reason, "--mode=block", "sleep", "infinity"];
inhibitorProcess.running = true;
}
// Wayland inhibition using wayhibitor or similar
function startWaylandInhibition() {
inhibitorProcess.command = ["wayhibitor"];
inhibitorProcess.running = true;
}
// Manual toggle for user control
function manualToggle() {
if (activeInhibitors.includes("manual")) {
removeInhibitor("manual");
return false;
} else {
addInhibitor("manual", "Manually activated by user");
return true;
}
}
Component.onCompleted: {
detectStrategy();
}
// Clean up on shutdown
Component.onDestruction: {
stopInhibition();
}
// Process for maintaining the inhibition
Process {
id: inhibitorProcess
running: false
onExited: function(exitCode, exitStatus) {
if (isInhibited)
isInhibited = false;
Logger.log("Caffeine", "Inhibitor process exited with code:", exitCode, "status:", exitStatus);
}
onStarted: function() {
Logger.log("Caffeine", "Inhibitor process started with PID:", inhibitorProcess.processId);
}
}
}

View File

@@ -0,0 +1,51 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Constants
Item {
IpcHandler {
function setPrimary(color: color) {
SettingsService.primaryColor = color;
}
target: "colors"
}
IpcHandler {
function toggleCalendar() {
calendarPanel.toggle();
}
function toggleControlCenter() {
controlCenterPanel.toggle();
}
target: "panels"
}
IpcHandler {
function toggleBarLyrics() {
SettingsService.showLyricsBar = !SettingsService.showLyricsBar;
}
target: "lyrics"
}
IpcHandler {
function toggleInhibitor() {
Caffeine.manualToggle();
}
target: "idleInhibitor"
}
IpcHandler {
function startOrStopRecording() {
RecordService.startOrStop();
}
target: "recording"
}
}

View File

@@ -0,0 +1,165 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
import qs.Utils
pragma Singleton
Singleton {
property alias ip: cacheFileAdapter.ip
property string cacheFilePath: CacheService.ipCacheFile
property string countryCode: "N/A"
property real fetchInterval: 120 // in s
property real fetchTimeout: 10 // in s
property string ipURL: "https://api.uyanide.com/ip"
property string geoURL: "https://api.ipinfo.io/lite/"
property string geoURLToken: ""
function fetchIP() {
curl.fetch(ipURL, function(success, data) {
if (success) {
try {
const response = JSON.parse(data);
if (response && response.ip) {
let newIP = response.ip;
Logger.log("IpService", "Fetched IP: " + newIP);
if (newIP !== ip) {
ip = newIP;
countryCode = "N/A";
fetchGeoInfo(true); // Fetch geo info only if IP has changed
}
} else {
Logger.error("IpService", "IP response does not contain 'ip' field");
}
} catch (e) {
Logger.error("IpService", "Failed to parse IP response: " + e);
}
} else {
Logger.error("IpService", "Failed to fetch IP");
}
});
}
function fetchGeoInfo(notify) {
if (!ip || ip === "N/A") {
countryCode = "N/A";
return ;
}
let url = geoURL + ip;
if (geoURLToken)
url += "?token=" + geoURLToken;
cacheFileAdapter.geoInfo = null
curl.fetch(url, function(success, data) {
if (success) {
try {
const response = JSON.parse(data);
if (response && (response.country_code || response.country)) {
let newCountryCode = response.country_code || response.country;
Logger.log("IpService", "Fetched country code: " + newCountryCode);
countryCode = newCountryCode;
} else {
Logger.error("IpService", "Geo response does not contain 'country_code' field");
}
cacheFileAdapter.geoInfo = response;
} catch (e) {
Logger.error("IpService", "Failed to parse geo response: " + e);
}
} else {
Logger.error("IpService", "Failed to fetch geo info");
}
SendNotification.show("New IP", `IP: ${ip}\nCountry: ${countryCode}`);
cacheFile.writeAdapter();
});
}
function refresh() {
fetchTimer.stop();
ip = "N/A";
fetchIP();
fetchTimer.start();
}
Component.onCompleted: {
}
NetworkFetch {
id: curl
}
Process {
id: ipMonitor
command: ["ip", "monitor", "address", "route"]
running: true
stdout: SplitParser {
splitMarker: "\n"
onRead: {
ipMonitorDebounce.restart();
}
}
}
Timer {
id: ipMonitorDebounce
interval: 1000
repeat: false
running: false
onTriggered: {
fetchIP();
}
}
Timer {
id: fetchTimer
interval: fetchInterval * 1000
repeat: true
running: true
onTriggered: {
fetchTimer.stop();
fetchIP();
fetchTimer.start();
}
}
FileView {
id: tokenFile
path: Qt.resolvedUrl("../Assets/Config/GeoInfoToken.txt")
onLoaded: {
geoURLToken = tokenFile.text();
if (!geoURLToken)
Logger.warn("IpService", "No token found for geoIP service, assuming none is required");
fetchIP();
fetchTimer.start();
}
}
FileView {
id: cacheFile
path: cacheFilePath
watchChanges: false
onLoaded: {
Logger.log("IpService", "Loaded IP from cache file: " + cacheFileAdapter.ip);
if (cacheFileAdapter.geoInfo) {
countryCode = cacheFileAdapter.geoInfo.country_code || cacheFileAdapter.country || "N/A";
Logger.log("IpService", "Loaded country code from cache file: " + countryCode);
}
}
JsonAdapter {
id: cacheFileAdapter
property string ip: "N/A"
property var geoInfo: null
}
}
}

View File

@@ -0,0 +1,318 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Constants
import qs.Services
import qs.Utils
pragma Singleton
// Weather logic and caching with stable UI properties
Singleton {
//console.log(JSON.stringify(weatherData))
id: root
property string locationName: SettingsService.location
property string locationFile: CacheService.locationCacheFile
property int weatherUpdateFrequency: 30 * 60 // 30 minutes expressed in seconds
property bool isFetchingWeather: false
readonly property alias data: adapter // Used to access via LocationService.data.xxx from outside, best to use "adapter" inside the service.
// Stable UI properties - only updated when location is fully resolved
property bool coordinatesReady: false
property string stableLatitude: ""
property string stableLongitude: ""
property string stableName: ""
// Helper property for UI components (outside JsonAdapter to avoid binding loops)
readonly property string displayCoordinates: {
if (!root.coordinatesReady || root.stableLatitude === "" || root.stableLongitude === "")
return "";
const lat = parseFloat(root.stableLatitude).toFixed(4);
const lon = parseFloat(root.stableLongitude).toFixed(4);
return `${lat}, ${lon}`;
}
// --------------------------------
function init() {
// does nothing but ensure the singleton is created
// do not remove
Logger.log("Location", "Service started");
}
// --------------------------------
function resetWeather() {
Logger.log("Location", "Resetting weather data");
// Mark as changing to prevent UI updates
root.coordinatesReady = false;
// Reset stable properties
root.stableLatitude = "";
root.stableLongitude = "";
root.stableName = "";
// Reset core data
adapter.latitude = "";
adapter.longitude = "";
adapter.name = "";
adapter.weatherLastFetch = 0;
adapter.weather = null;
// Try to fetch immediately
updateWeather();
}
// --------------------------------
function updateWeather() {
if (isFetchingWeather) {
Logger.warn("Location", "Weather is still fetching");
return ;
}
if ((adapter.weatherLastFetch === "") || (adapter.weather === null) || (adapter.latitude === "") || (adapter.longitude === "") || (adapter.name !== root.locationName) || (Time.timestamp >= adapter.weatherLastFetch + weatherUpdateFrequency))
getFreshWeather();
}
// --------------------------------
function getFreshWeather() {
isFetchingWeather = true;
// Check if location name has changed
const locationChanged = data.name !== root.locationName;
if (locationChanged) {
root.coordinatesReady = false;
Logger.log("Location", "Location changed from", adapter.name, "to", root.locationName);
}
if ((adapter.latitude === "") || (adapter.longitude === "") || locationChanged)
_geocodeLocation(root.locationName, function(latitude, longitude, name, country) {
Logger.log("Location", "Geocoded", root.locationName, "to:", latitude, "/", longitude);
// Save location name
adapter.name = root.locationName;
// Save GPS coordinates
adapter.latitude = latitude.toString();
adapter.longitude = longitude.toString();
root.stableName = `${name}, ${country}`;
_fetchWeather(latitude, longitude, errorCallback);
}, errorCallback);
else
_fetchWeather(adapter.latitude, adapter.longitude, errorCallback);
}
// --------------------------------
function _geocodeLocation(locationName, callback, errorCallback) {
Logger.log("Location", "Geocoding location name");
var geoUrl = "https://assets.noctalia.dev/geocode.php?city=" + encodeURIComponent(locationName) + "&language=en&format=json";
curl.fetch(geoUrl, function(success, data) {
if (success) {
try {
var geoData = JSON.parse(data);
if (geoData.lat != null)
callback(geoData.lat, geoData.lng, geoData.name, geoData.country);
else
errorCallback("Location", "could not resolve location name");
} catch (e) {
errorCallback("Location", "Failed to parse geocoding data: " + e);
}
} else {
errorCallback("Location", "Geocoding error");
}
});
}
// --------------------------------
function _fetchWeather(latitude, longitude, errorCallback) {
Logger.log("Location", "Fetching weather from api.open-meteo.com");
var url = "https://api.open-meteo.com/v1/forecast?latitude=" + latitude + "&longitude=" + longitude + "&current_weather=true&current=relativehumidity_2m,surface_pressure&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto";
curl.fetch(url, function(success, fetchedData) {
if (success) {
try {
var weatherData = JSON.parse(fetchedData);
// Save core data
data.weather = weatherData;
data.weatherLastFetch = Time.timestamp;
// Update stable display values only when complete and successful
root.stableLatitude = data.latitude = weatherData.latitude.toString();
root.stableLongitude = data.longitude = weatherData.longitude.toString();
root.coordinatesReady = true;
isFetchingWeather = false;
Logger.log("Location", "Cached weather to disk - stable coordinates updated");
} catch (e) {
errorCallback("Location", "Failed to parse weather data: " + e);
}
} else {
errorCallback("Location", "Weather fetch error");
}
});
}
// --------------------------------
function errorCallback(module, message) {
Logger.error(module, message);
isFetchingWeather = false;
}
// --------------------------------
function weatherSymbolFromCode(code) {
if (code === 0)
return "weather-sun";
if (code === 1 || code === 2)
return "weather-cloud-sun";
if (code === 3)
return "weather-cloud";
if (code >= 45 && code <= 48)
return "weather-cloud-haze";
if (code >= 51 && code <= 67)
return "weather-cloud-rain";
if (code >= 71 && code <= 77)
return "weather-cloud-snow";
if (code >= 71 && code <= 77)
return "weather-cloud-snow";
if (code >= 85 && code <= 86)
return "weather-cloud-snow";
if (code >= 95 && code <= 99)
return "weather-cloud-lightning";
return "weather-cloud";
}
function weatherColorFromCode(code) {
// Clear sky - bright yellow
if (code === 0)
return Colors.yellow;
// Mainly clear/Partly cloudy - soft peach/rosewater tones
if (code === 1 || code === 2)
return Colors.peach;
// Overcast - neutral sky blue
if (code === 3)
return Colors.sky;
// Fog - soft lavender/muted tone
if (code >= 45 && code <= 48)
return Colors.lavender;
// Drizzle - light blue/sapphire
if (code >= 51 && code <= 67)
return Colors.sapphire;
// Snow - cool teal
if (code >= 71 && code <= 77)
return Colors.teal;
// Rain showers - deeper blue
if (code >= 80 && code <= 82)
return Colors.blue;
// Snow showers - teal
if (code >= 85 && code <= 86)
return Colors.teal;
// Thunderstorm - dramatic mauve/pink
if (code >= 95 && code <= 99)
return Colors.mauve;
// Default - sky blue
return Colors.sky;
}
// --------------------------------
function weatherDescriptionFromCode(code) {
if (code === 0)
return "Clear sky";
if (code === 1)
return "Mainly clear";
if (code === 2)
return "Partly cloudy";
if (code === 3)
return "Overcast";
if (code === 45 || code === 48)
return "Fog";
if (code >= 51 && code <= 67)
return "Drizzle";
if (code >= 71 && code <= 77)
return "Snow";
if (code >= 80 && code <= 82)
return "Rain showers";
if (code >= 95 && code <= 99)
return "Thunderstorm";
return "Unknown";
}
// --------------------------------
function celsiusToFahrenheit(celsius) {
return 32 + celsius * 1.8;
}
FileView {
id: locationFileView
path: locationFile
printErrors: false
onAdapterUpdated: saveTimer.start()
onLoaded: {
Logger.log("Location", "Loaded cached data");
// Initialize stable properties on load
if (adapter.latitude !== "" && adapter.longitude !== "" && adapter.weatherLastFetch > 0) {
root.stableLatitude = adapter.latitude;
root.stableLongitude = adapter.longitude;
root.stableName = adapter.name;
root.coordinatesReady = true;
Logger.log("Location", "Coordinates ready");
}
updateWeather();
}
onLoadFailed: function(error) {
updateWeather();
}
JsonAdapter {
id: adapter
// Core data properties
property string latitude: ""
property string longitude: ""
property string name: ""
property int weatherLastFetch: 0
property var weather: null
}
}
// Every 20s check if we need to fetch new weather
Timer {
id: updateTimer
interval: 20 * 1000
running: true
repeat: true
onTriggered: {
updateWeather();
}
}
Timer {
id: saveTimer
running: false
interval: 1000
onTriggered: locationFileView.writeAdapter()
}
NetworkFetch {
id: curl
}
}

View File

@@ -0,0 +1,141 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
import qs.Utils
pragma Singleton
Singleton {
property int linesCount: 3
property int linesAhead: linesCount / 2
property int currentIndex: linesCount - linesAhead - 1
property string offsetFile: CacheService.lyricsOffsetCacheFile
property int offset: 0 // in ms
property int offsetStep: 500 // in ms
property int referenceCount: 0
// with linesCount=3 and linesAhead=1, lyrics will be like:
// line 1
// line 2 <- current line
// line 3
property var lyrics: Array(linesCount).fill(" ")
function startSyncing() {
referenceCount++;
Logger.log("LyricsService", "Reference count:", referenceCount);
if (referenceCount === 1) {
Logger.log("LyricsService", "Starting lyrics syncing");
// fill lyrics with empty lines
lyrics = Array(linesCount).fill(" ");
listenProcess.exec(["sh", "-c", `sl-wrap listen -l ${linesCount} -a ${linesAhead} -f ${offsetFile}`]);
}
}
function stopSyncing() {
referenceCount--;
Logger.log("LyricsService", "Reference count:", referenceCount);
if (referenceCount <= 0) {
Logger.log("LyricsService", "Stopping lyrics syncing");
// Execute again to stop
// kinda ugly but works, but meanwhile:
// listenProcess.signal(9)
// listenProcess.signal(15)
// listenProcess.running = false
// counts on exec() to terminate previous exec()
// all don't work
listenProcess.exec(["sh", "-c", `sl-wrap trackid`]);
}
}
function writeOffset() {
offsetFileView.setText(String(offset));
}
function increaseOffset() {
offset += offsetStep;
}
function decreaseOffset() {
offset -= offsetStep;
}
function resetOffset() {
offset = 0;
}
function clearCache() {
action.command = ["sh", "-c", "spotify-lyrics clear $(spotify-lyrics trackid)"];
action.startDetached();
}
function showLyricsText() {
action.command = ["sh", "-c", "ghostty -e sh -c 'spotify-lyrics fetch | less'"];
action.startDetached();
}
onOffsetChanged: {
if (SettingsService.showLyricsBar)
SendNotification.show("Lyrics Offset Changed", `Current offset: ${offset} ms`);
writeOffset();
}
Process {
id: listenProcess
running: false
stdout: SplitParser {
splitMarker: ""
onRead: (data) => {
lyrics = data.split("\n").slice(0, linesCount);
if (lyrics.length < linesCount) {
// fill with empty lines if not enough
for (let i = lyrics.length; i < linesCount; i++) {
lyrics[i] = " ";
}
}
}
}
}
Process {
id: action
running: false
}
FileView {
id: offsetFileView
path: offsetFile
watchChanges: false
onLoaded: {
try {
const fileContents = text();
if (fileContents.length > 0) {
const val = parseInt(fileContents);
if (!isNaN(val)) {
offset = val;
Logger.log("LyricsService", "Loaded offset:", offset);
} else {
offset = 0;
writeOffset();
}
} else {
offset = 0;
writeOffset();
}
} catch (e) {
Logger.error("LyricsService", "Error reading offset file:", e);
}
}
onLoadFailed: {
Logger.error("LyricsService", "Error loading offset file:", errorString);
}
onSaveFailed: {
Logger.error("LyricsService", "Error saving offset file:", errorString);
}
}
}

View File

@@ -0,0 +1,180 @@
import QtQuick
import Quickshell
import Quickshell.Services.Mpris
import qs.Modules.Misc
import qs.Utils
pragma Singleton
Singleton {
id: manager
// Properties
property var currentPlayer: null
property real currentPosition: 0
property bool isPlaying: currentPlayer ? currentPlayer.isPlaying : false
property int selectedPlayerIndex: -1
property string trackTitle: currentPlayer ? (currentPlayer.trackTitle || "Unknown Track") : ""
property string trackArtist: currentPlayer ? (currentPlayer.trackArtist || "Unknown Artist") : ""
property string trackAlbum: currentPlayer ? (currentPlayer.trackAlbum || "Unknown Album") : ""
property string trackArtUrl: currentPlayer ? (currentPlayer.trackArtUrl || "") : ""
property real trackLength: currentPlayer ? currentPlayer.length : 0
property bool canPlay: currentPlayer ? currentPlayer.canPlay : false
property bool canPause: currentPlayer ? currentPlayer.canPause : false
property bool canGoNext: currentPlayer ? currentPlayer.canGoNext : false
property bool canGoPrevious: currentPlayer ? currentPlayer.canGoPrevious : false
property bool canSeek: currentPlayer ? currentPlayer.canSeek : false
property bool hasPlayer: getAvailablePlayers().length > 0
// Expose cava values
property alias cavaValues: cava.values
// Returns available MPRIS players
function getAvailablePlayers() {
if (!Mpris.players || !Mpris.players.values)
return [];
let allPlayers = Mpris.players.values;
let controllablePlayers = [];
for (let i = 0; i < allPlayers.length; i++) {
let player = allPlayers[i];
if (player && player.canControl)
controllablePlayers.push(player);
}
return controllablePlayers;
}
// Returns active player or first available
function findActivePlayer() {
let availablePlayers = getAvailablePlayers();
if (availablePlayers.length === 0)
return null;
// Get the first playing player
for (let i = availablePlayers.length - 1; i >= 0; i--) {
if (availablePlayers[i].isPlaying)
return availablePlayers[i];
}
// Fallback to last player
return availablePlayers[availablePlayers.length - 1];
}
// Updates currentPlayer and currentPosition
function updateCurrentPlayer() {
// Use selected player if index is valid
if (selectedPlayerIndex >= 0) {
let availablePlayers = getAvailablePlayers();
if (selectedPlayerIndex < availablePlayers.length) {
currentPlayer = availablePlayers[selectedPlayerIndex];
currentPosition = currentPlayer.position;
Logger.log("MusicManager", "Current player set by index:", currentPlayer ? currentPlayer.identity : "None");
return ;
} else {
selectedPlayerIndex = -1; // Reset if index is out of range
}
}
// Otherwise, find active player
let newPlayer = findActivePlayer();
if (newPlayer !== currentPlayer) {
currentPlayer = newPlayer;
currentPosition = currentPlayer ? currentPlayer.position : 0;
}
Logger.log("MusicManager", "Current player updated:", currentPlayer ? currentPlayer.identity : "None");
}
// Player control functions
function playPause() {
if (currentPlayer) {
if (currentPlayer.isPlaying)
currentPlayer.pause();
else
currentPlayer.play();
}
}
function isAllPaused() {
let availablePlayers = getAvailablePlayers();
for (let i = 0; i < availablePlayers.length; i++) {
if (availablePlayers[i].isPlaying)
return false;
}
return true;
}
function play() {
if (currentPlayer && currentPlayer.canPlay)
currentPlayer.play();
}
function pause() {
if (currentPlayer && currentPlayer.canPause)
currentPlayer.pause();
}
function next() {
if (currentPlayer && currentPlayer.canGoNext)
currentPlayer.next();
}
function previous() {
if (currentPlayer && currentPlayer.canGoPrevious)
currentPlayer.previous();
}
function seek(position) {
if (currentPlayer && currentPlayer.canSeek) {
currentPlayer.position = position;
currentPosition = position;
}
}
function seekByRatio(ratio) {
if (currentPlayer && currentPlayer.canSeek && currentPlayer.length > 0) {
let seekPosition = ratio * currentPlayer.length;
currentPlayer.position = seekPosition;
currentPosition = seekPosition;
}
}
// Initialize
Item {
Component.onCompleted: {
updateCurrentPlayer();
}
}
// Updates progress bar every second
Timer {
id: positionTimer
interval: 1000
running: currentPlayer && currentPlayer.isPlaying && currentPlayer.length > 0
repeat: true
onTriggered: {
if (currentPlayer && currentPlayer.isPlaying)
currentPosition = currentPlayer.position;
}
}
// Reacts to player list changes
Connections {
function onValuesChanged() {
updateCurrentPlayer();
}
target: Mpris.players
}
Cava {
id: cava
count: 44
}
}

View File

@@ -0,0 +1,65 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Utils
Item {
id: root
property real fetchTimeout: 10 // in seconds
property string fetchedData: ""
property var fetchingCallback: null
function fetch(url, callback) {
if (curlProcess.running) {
Logger.warn("NetworkFetch", "A fetch operation is already in progress.");
return ;
}
fetchedData = "";
fetchingCallback = callback;
curlProcess.command = ["curl", "-s", "-L", "-m", fetchTimeout.toString(), url];
curlProcess.running = true;
}
function fakeFetch(resp, callback) {
if (curlProcess.running) {
Logger.warn("NetworkFetch", "A fetch operation is already in progress.");
return ;
}
fetchedData = "";
fetchingCallback = callback;
curlProcess.command = ["echo", resp];
curlProcess.running = true;
}
Process {
id: curlProcess
running: false
onStarted: {
Logger.log("NetworkFetch", "Process started with command: " + curlProcess.command.join(" "));
}
onExited: function(exitCode, exitStatus) {
if (!fetchingCallback) {
Logger.error("NetworkFetch", "No callback defined for fetch operation.");
return ;
}
if (exitCode === 0) {
Logger.log("NetworkFetch", "Fetched data: " + fetchedData);
fetchingCallback(true, fetchedData);
} else {
Logger.error("NetworkFetch", "Fetch failed with exit code: " + exitCode);
fetchingCallback(false, "");
}
}
stdout: SplitParser {
splitMarker: ""
onRead: (data) => {
fetchedData += data;
}
}
}
}

View File

@@ -0,0 +1,190 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Utils
pragma Singleton
pragma ComponentBehavior: Bound
Singleton {
id: root
property var workspaces: []
property var windows: {}
property int focusedWindowId: -1
property bool noFocus: focusedWindowId === -1
property bool inOverview: false
property string focusedWindowTitle: ""
property string focusedWindowAppId: ""
function updateFocusedWindowTitle() {
if (windows && windows[focusedWindowId]) {
focusedWindowTitle = windows[focusedWindowId].title || "";
focusedWindowAppId = windows[focusedWindowId].appId || "";
} else {
focusedWindowTitle = "";
focusedWindowAppId = "";
}
}
function getFocusedWindow() {
return (windows && windows[focusedWindowId]) || null;
}
Component.onCompleted: {
eventStream.running = true;
}
Process {
id: workspaceProcess
running: false
command: ["niri", "msg", "--json", "workspaces"]
stdout: SplitParser {
onRead: function(line) {
try {
const workspacesData = JSON.parse(line);
const workspacesList = [];
for (const ws of workspacesData) {
workspacesList.push({
"id": ws.id,
"idx": ws.idx,
"name": ws.name || "",
"output": ws.output || "",
"isFocused": ws.is_focused === true,
"isActive": ws.is_active === true,
"isUrgent": ws.is_urgent === true,
"activeWindowId": ws.active_window_id
});
}
workspacesList.sort((a, b) => {
if (a.output !== b.output)
return a.output.localeCompare(b.output);
return a.id - b.id;
});
root.workspaces = workspacesList;
} catch (e) {
Logger.error("Niri", "Failed to parse workspaces:", e, line);
}
}
}
}
Process {
id: eventStream
running: false
command: ["niri", "msg", "--json", "event-stream"]
stdout: SplitParser {
onRead: (data) => {
try {
const event = JSON.parse(data.trim());
if (event.WorkspacesChanged) {
workspaceProcess.running = true;
} else if (event.WindowsChanged) {
try {
const windowsData = event.WindowsChanged.windows;
const windowsMap = {};
for (const win of windowsData) {
if (win.is_focused === true) {
root.focusedWindowId = win.id;
}
windowsMap[win.id] = {
"title": win.title || "",
"appId": win.app_id || "",
"workspaceId": win.workspace_id || null,
"isFocused": win.is_focused === true
};
}
root.windows = windowsMap;
root.updateFocusedWindowTitle();
} catch (e) {
Logger.error("Niri", "Error parsing windows event:", e);
}
} else if (event.WorkspaceActivated) {
workspaceProcess.running = true;
} else if (event.WindowFocusChanged) {
try {
const focusedId = event.WindowFocusChanged.id;
if (focusedId) {
if (root.windows[focusedId]) {
root.focusedWindowId = focusedId;
} else {
root.focusedWindowId = -1;
}
} else {
root.focusedWindowId = -1;
}
root.updateFocusedWindowTitle();
} catch (e) {
Logger.error("Niri", "Error parsing window focus event:", e);
}
} else if (event.OverviewOpenedOrClosed) {
try {
root.inOverview = event.OverviewOpenedOrClosed.is_open === true;
} catch (e) {
Logger.error("Niri", "Error parsing overview state:", e);
}
} else if (event.WindowOpenedOrChanged) {
try {
const targetWin = event.WindowOpenedOrChanged.window;
const id = targetWin.id;
const isFocused = targetWin.is_focused === true;
let needUpdateTitle = false;
if (id) {
if (root.windows && root.windows[id]) {
const win = root.windows[id];
// Update existing window
needUpdateTitle = win.title !== targetWin.title;
win.title = targetWin.title || win.title;
win.appId = targetWin.app_id || win.appId;
win.workspaceId = targetWin.workspace_id || win.workspaceId;
win.isFocused = isFocused;
} else {
// New window
const newWin = {
"title": targetWin.title || "",
"appId": targetWin.app_id || "",
"workspaceId": targetWin.workspace_id || null,
"isFocused": isFocused
};
root.windows[id] = targetWin;
}
if (isFocused) {
if (root.focusedWindowId !== id || needUpdateTitle){
root.focusedWindowId = id;
root.updateFocusedWindowTitle();
}
}
}
} catch (e) {
Logger.error("Niri", "Error parsing window opened/changed event:", e);
}
} else if (event.windowClosed) {
try {
const closedId = event.windowClosed.id;
if (closedId && (root.windows && root.windows[closedId])) {
delete root.windows[closedId];
if (root.focusedWindowId === closedId) {
root.focusedWindowId = -1;
root.updateFocusedWindowTitle();
}
}
} catch (e) {
Logger.error("Niri", "Error parsing window closed event:", e);
}
}
} catch (e) {
Logger.error("Niri", "Error parsing event stream:", e, data);
}
}
}
}
}

View File

@@ -0,0 +1,474 @@
pragma Singleton
import QtQuick
import QtQuick.Window
import Quickshell
import Quickshell.Io
import Quickshell.Services.Notifications
import qs.Utils
import qs.Services
import qs.Constants
import "../Utils/sha256.js" as Checksum
Singleton {
id: root
// Configuration
property int maxVisible: 5
property int maxHistory: 100
property string historyFile: CacheService.notificationsCacheFile
property string cacheDirImagesNotifications: Quickshell.env("HOME") + "/.cache/quickshell/notifications/"
property real lowUrgencyDuration: 3
property real normalUrgencyDuration: 8
property real criticalUrgencyDuration: 15
// Models
property ListModel activeList: ListModel {}
property ListModel historyList: ListModel {}
// Internal state
property var activeMap: ({})
property var imageQueue: []
// Performance optimization: Track notification metadata separately
property var notificationMetadata: ({}) // Stores timestamp and duration for each notification
PanelWindow {
implicitHeight: 1
implicitWidth: 1
color: Color.transparent
mask: Region {}
Image {
id: cacher
width: 64
height: 64
visible: true
cache: false
asynchronous: true
mipmap: true
antialiasing: true
onStatusChanged: {
if (imageQueue.length === 0)
return
const req = imageQueue[0]
if (status === Image.Ready) {
Quickshell.execDetached(["mkdir", "-p", cacheDirImagesNotifications])
grabToImage(result => {
if (result.saveToFile(req.dest))
updateImagePath(req.imageId, req.dest)
processNextImage()
})
} else if (status === Image.Error) {
processNextImage()
}
}
function processNextImage() {
imageQueue.shift()
if (imageQueue.length > 0) {
source = imageQueue[0].src
} else {
source = ""
}
}
}
}
// Notification server
NotificationServer {
keepOnReload: false
imageSupported: true
actionsSupported: true
onNotification: notification => handleNotification(notification)
}
// Main handler
function handleNotification(notification) {
const data = createData(notification)
addToHistory(data)
if (SettingsService.notifications.doNotDisturb)
return
activeMap[data.id] = notification
notification.tracked = true
notification.closed.connect(() => removeActive(data.id))
// Store metadata for efficient progress calculation
const durations = [lowUrgencyDuration * 1000, normalUrgencyDuration * 1000, criticalUrgencyDuration * 1000]
let expire = 0
if (data.expireTimeout === 0) {
expire = -1 // Never expire
} else if (data.expireTimeout > 0) {
expire = data.expireTimeout
} else {
expire = durations[data.urgency]
}
notificationMetadata[data.id] = {
"timestamp": data.timestamp.getTime(),
"duration": expire,
"urgency": data.urgency
}
activeList.insert(0, data)
while (activeList.count > maxVisible) {
const last = activeList.get(activeList.count - 1)
activeMap[last.id]?.dismiss()
activeList.remove(activeList.count - 1)
delete notificationMetadata[last.id]
}
}
function createData(n) {
const time = new Date()
const id = Checksum.sha256(JSON.stringify({
"summary": n.summary,
"body": n.body,
"app": n.appName,
"time": time.getTime()
}))
const image = n.image || getIcon(n.appIcon)
const imageId = generateImageId(n, image)
queueImage(image, imageId)
return {
"id": id,
"summary": (n.summary || ""),
"body": stripTags(n.body || ""),
"appName": getAppName(n.appName),
"urgency": n.urgency < 0 || n.urgency > 2 ? 1 : n.urgency,
"expireTimeout": n.expireTimeout,
"timestamp": time,
"progress": 1.0,
"originalImage": image,
"cachedImage": imageId ? (cacheDirImagesNotifications + imageId + ".png") : image,
"actionsJson": JSON.stringify((n.actions || []).map(a => ({
"text": a.text || "Action",
"identifier": a.identifier || ""
})))
}
}
function queueImage(path, imageId) {
if (!path || !path.startsWith("image://") || !imageId)
return
const dest = cacheDirImagesNotifications + imageId + ".png"
for (const req of imageQueue) {
if (req.imageId === imageId)
return
}
imageQueue.push({
"src": path,
"dest": dest,
"imageId": imageId
})
if (imageQueue.length === 1)
cacher.source = path
}
function updateImagePath(id, path) {
updateModel(activeList, id, "cachedImage", path)
updateModel(historyList, id, "cachedImage", path)
saveHistory()
}
function updateModel(model, id, prop, value) {
for (var i = 0; i < model.count; i++) {
if (model.get(i).id === id) {
model.setProperty(i, prop, value)
break
}
}
}
function removeActive(id) {
for (var i = 0; i < activeList.count; i++) {
if (activeList.get(i).id === id) {
activeList.remove(i)
delete activeMap[id]
delete notificationMetadata[id]
break
}
}
}
// Optimized batch progress update
Timer {
interval: 50 // Reduced from 10ms to 50ms (20 updates/sec instead of 100)
repeat: true
running: activeList.count > 0
onTriggered: updateAllProgress()
}
function updateAllProgress() {
const now = Date.now()
const toRemove = []
const updates = [] // Batch updates
// Collect all updates first
for (var i = 0; i < activeList.count; i++) {
const notif = activeList.get(i)
const meta = notificationMetadata[notif.id]
if (!meta || meta.duration === -1)
continue
// Skip infinite notifications
const elapsed = now - meta.timestamp
const progress = Math.max(1.0 - (elapsed / meta.duration), 0.0)
if (progress <= 0) {
toRemove.push(notif.id)
} else if (Math.abs(notif.progress - progress) > 0.005) {
// Only update if change is significant
updates.push({
"index": i,
"progress": progress
})
}
}
// Apply batch updates
for (const update of updates) {
activeList.setProperty(update.index, "progress", update.progress)
}
// Remove expired notifications (one at a time to allow animation)
if (toRemove.length > 0) {
animateAndRemove(toRemove[0])
}
}
// History management
function addToHistory(data) {
historyList.insert(0, data)
while (historyList.count > maxHistory) {
const old = historyList.get(historyList.count - 1)
if (old.cachedImage && !old.cachedImage.startsWith("image://")) {
Quickshell.execDetached(["rm", "-f", old.cachedImage])
}
historyList.remove(historyList.count - 1)
}
saveHistory()
}
// Persistence
FileView {
id: historyFileView
path: historyFile
printErrors: false
onLoaded: loadHistory()
onLoadFailed: error => {
if (error === 2)
writeAdapter()
}
JsonAdapter {
id: adapter
property var notifications: []
}
}
Timer {
id: saveTimer
interval: 200
onTriggered: performSaveHistory()
}
function saveHistory() {
saveTimer.restart()
}
function performSaveHistory() {
try {
const items = []
for (var i = 0; i < historyList.count; i++) {
const n = historyList.get(i)
const copy = Object.assign({}, n)
copy.timestamp = n.timestamp.getTime()
items.push(copy)
}
adapter.notifications = items
historyFileView.writeAdapter()
} catch (e) {
Logger.error("Notifications", "Save history failed:", e)
}
}
function loadHistory() {
try {
historyList.clear()
for (const item of adapter.notifications || []) {
const time = new Date(item.timestamp)
let cachedImage = item.cachedImage || ""
if (item.originalImage && item.originalImage.startsWith("image://") && !cachedImage) {
const imageId = generateImageId(item, item.originalImage)
if (imageId) {
cachedImage = cacheDirImagesNotifications + imageId + ".png"
}
}
historyList.append({
"id": item.id || "",
"summary": item.summary || "",
"body": item.body || "",
"appName": item.appName || "",
"urgency": item.urgency < 0 || item.urgency > 2 ? 1 : item.urgency,
"timestamp": time,
"originalImage": item.originalImage || "",
"cachedImage": cachedImage
})
}
} catch (e) {
Logger.error("Notifications", "Load failed:", e)
}
}
function getAppName(name) {
if (!name || name.trim() === "")
return "Unknown"
name = name.trim()
if (name.includes(".") && (name.startsWith("com.") || name.startsWith("org.") || name.startsWith("io.") || name.startsWith("net."))) {
const parts = name.split(".")
let appPart = parts[parts.length - 1]
if (!appPart || appPart === "app" || appPart === "desktop") {
appPart = parts[parts.length - 2] || parts[0]
}
if (appPart) {
name = appPart
}
}
if (name.includes(".")) {
const parts = name.split(".")
let displayName = parts[parts.length - 1]
if (!displayName || /^\d+$/.test(displayName)) {
displayName = parts[parts.length - 2] || parts[0]
}
if (displayName) {
displayName = displayName.charAt(0).toUpperCase() + displayName.slice(1)
displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2')
displayName = displayName.replace(/app$/i, '').trim()
displayName = displayName.replace(/desktop$/i, '').trim()
displayName = displayName.replace(/flatpak$/i, '').trim()
if (!displayName) {
displayName = parts[parts.length - 1].charAt(0).toUpperCase() + parts[parts.length - 1].slice(1)
}
}
return displayName || name
}
let displayName = name.charAt(0).toUpperCase() + name.slice(1)
displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2')
displayName = displayName.replace(/app$/i, '').trim()
displayName = displayName.replace(/desktop$/i, '').trim()
return displayName || name
}
function getIcon(icon) {
if (!icon)
return ""
if (icon.startsWith("/") || icon.startsWith("file://"))
return icon
return ThemeIcons.iconFromName(icon)
}
function stripTags(text) {
return text.replace(/<[^>]*>?/gm, '')
}
function generateImageId(notification, image) {
if (image && image.startsWith("image://")) {
if (image.startsWith("image://qsimage/")) {
const key = (notification.appName || "") + "|" + (notification.summary || "")
return Checksum.sha256(key)
}
return Checksum.sha256(image)
}
return ""
}
// Public API
function dismissActiveNotification(id) {
activeMap[id]?.dismiss()
removeActive(id)
}
function dismissAllActive() {
Object.values(activeMap).forEach(n => n.dismiss())
activeList.clear()
activeMap = {}
notificationMetadata = {}
}
function invokeAction(id, actionId) {
const n = activeMap[id]
if (!n?.actions)
return false
for (const action of n.actions) {
if (action.identifier === actionId && action.invoke) {
action.invoke()
return true
}
}
return false
}
function removeFromHistory(notificationId) {
for (var i = 0; i < historyList.count; i++) {
const notif = historyList.get(i)
if (notif.id === notificationId) {
if (notif.cachedImage && !notif.cachedImage.startsWith("image://")) {
Quickshell.execDetached(["rm", "-f", notif.cachedImage])
}
historyList.remove(i)
saveHistory()
return true
}
}
return false
}
function clearHistory() {
try {
Quickshell.execDetached(["sh", "-c", `rm -rf "${cacheDirImagesNotifications}"*`])
} catch (e) {
Logger.error("Notifications", "Failed to clear cache directory:", e)
}
historyList.clear()
saveHistory()
}
// Signals & connections
signal animateAndRemove(string notificationId)
Connections {
target: SettingsService.notifications
function onDoNotDisturbChanged() {
const enabled = SettingsService.notifications.doNotDisturb
}
}
}

View File

@@ -0,0 +1,23 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Utils
pragma Singleton
Singleton {
property bool done: false
Process {
id: process
running: true
command: ["kquitapp6", "kded6"]
onExited: (code, status) => {
if (code !== 0)
Logger.warn("NukeKded6", `Failed to kill kded6: ${code}`);
done = true;
}
}
}

View File

@@ -0,0 +1,67 @@
pragma Singleton
import Quickshell
Singleton {
id: root
// A ref. to the lockScreen, so it's accessible from anywhere
// This is not a panel...
property var lockScreen: null
// Panels
property var registeredPanels: ({})
property var openedPanel: null
signal willOpen
// Currently opened popups, can have more than one.
// ex: when opening an NIconPicker from a widget setting.
property var openedPopups: []
property bool hasOpenedPopup: false
signal popupChanged
// Register this panel
function registerPanel(panel) {
registeredPanels[panel.objectName] = panel
}
// Returns a panel
function getPanel(name) {
return registeredPanels[name] || null
}
// Check if a panel exists
function hasPanel(name) {
return name in registeredPanels
}
// Helper to keep only one panel open at any time
function willOpenPanel(panel) {
if (openedPanel && openedPanel !== panel) {
openedPanel.close()
}
openedPanel = panel
// emit signal
willOpen()
}
function closedPanel(panel) {
if (openedPanel && openedPanel === panel) {
openedPanel = null
}
}
// Popups
function willOpenPopup(popup) {
openedPopups.push(popup)
hasOpenedPopup = (openedPopups.length !== 0)
popupChanged()
}
function willClosePopup(popup) {
openedPopups = openedPopups.filter(p => p !== popup)
hasOpenedPopup = (openedPopups.length !== 0)
popupChanged()
}
}

View File

@@ -0,0 +1,88 @@
import QtQuick
import Quickshell
import Quickshell.Services.UPower
import qs.Services
import qs.Utils
pragma Singleton
Singleton {
id: root
readonly property var powerProfiles: PowerProfiles
readonly property bool available: powerProfiles && powerProfiles.hasPerformanceProfile
property int profile: powerProfiles ? powerProfiles.profile : PowerProfile.Balanced
function getName(p) {
if (!available)
return "Unknown";
const prof = (p !== undefined) ? p : profile;
switch (prof) {
case PowerProfile.Performance:
return "Performance";
case PowerProfile.Balanced:
return "Balanced";
case PowerProfile.PowerSaver:
return "Power saver";
default:
return "Unknown";
}
}
function getIcon(p) {
if (!available)
return "balanced";
const prof = (p !== undefined) ? p : profile;
switch (prof) {
case PowerProfile.Performance:
return "performance";
case PowerProfile.Balanced:
return "balanced";
case PowerProfile.PowerSaver:
return "powersaver";
default:
return "balanced";
}
}
function setProfile(p) {
if (!available)
return ;
try {
powerProfiles.profile = p;
} catch (e) {
Logger.error("PowerProfileService", "Failed to set profile:", e);
}
}
function cycleProfile() {
if (!available)
return ;
const current = powerProfiles.profile;
if (current === PowerProfile.Performance)
setProfile(PowerProfile.PowerSaver);
else if (current === PowerProfile.Balanced)
setProfile(PowerProfile.Performance);
else if (current === PowerProfile.PowerSaver)
setProfile(PowerProfile.Balanced);
}
Connections {
function onProfileChanged() {
root.profile = powerProfiles.profile;
// Only show toast if we have a valid profile name (not "Unknown")
const profileName = root.getName();
if (profileName !== "Unknown")
ToastService.showNotice(I18n.tr("toast.power-profile.changed"), I18n.tr("toast.power-profile.profile-name", {
"profile": profileName
}));
}
target: powerProfiles
}
}

View File

@@ -0,0 +1,163 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
import qs.Utils
pragma Singleton
Singleton {
property string recordingDir: CacheService.recordingDir
property bool isRecording: false
property bool isStopping: false
property string codec: "av1_nvenc"
property string container: "mkv"
property string pixelFormat: "p010le"
property string recordingDisplay: ""
property int framerate: 60
property var codecParams: ["preset=p5", "rc=vbr", "cq=18", "b:v=80M", "maxrate=120M", "bufsize=160M", "color_range=tv"]
property var filterArgs: []
function getFilename() {
var d = new Date();
var year = d.getFullYear();
var month = ("0" + (d.getMonth() + 1)).slice(-2);
var day = ("0" + d.getDate()).slice(-2);
var hours = ("0" + d.getHours()).slice(-2);
var minutes = ("0" + d.getMinutes()).slice(-2);
var seconds = ("0" + d.getSeconds()).slice(-2);
return "recording_" + year + "-" + month + "-" + day + "_" + hours + "." + minutes + "." + seconds + "." + container;
}
function getAudioSink() {
return AudioService.sink ? AudioService.sink.name + '.monitor' : null; // this works on my machine :)
}
function getVideoSource(callback) {
if (niriFocusedOutputProcess.running) {
Logger.warn("RecordService", "Already fetching focused output, returning null.");
callback(null);
}
niriFocusedOutputProcess.onGetName = callback;
niriFocusedOutputProcess.running = true;
}
function startOrStop() {
if (isRecording)
stop();
else
start();
}
function stop() {
if (!isRecording) {
Logger.warn("RecordService", "Not currently recording, cannot stop.");
return ;
}
if (isStopping) {
Logger.warn("RecordService", "Already stopping, please wait.");
return ;
}
isStopping = true;
recordProcess.signal(15);
}
function start() {
if (isRecording || isStopping) {
Logger.warn("RecordService", "Already recording, cannot start.");
return ;
}
isRecording = true;
getVideoSource((source) => {
if (!source) {
SendNotification.show("Recording failed", "Could not determine which display to record from.");
return ;
}
recordingDisplay = source;
const audioSink = getAudioSink();
if (!audioSink) {
SendNotification.show("Recording failed", "No audio sink available to record from.");
return ;
}
recordProcess.filePath = recordingDir + getFilename();
recordProcess.command = ["wf-recorder", "--audio=" + audioSink, "-o", source, "--codec", codec, "--pixel-format", pixelFormat, "--framerate", framerate.toString(), "-f", recordProcess.filePath];
for (const param of codecParams) {
recordProcess.command.push("-p");
recordProcess.command.push(param);
}
for (const filter of filterArgs) {
recordProcess.command.push("-F");
recordProcess.command.push(filter);
}
Logger.log("RecordService", "Starting recording with command: " + recordProcess.command.join(" "));
recordProcess.onErrorExit = function() {
SendNotification.show("Recording failed", "An error occurred while trying to record the screen.");
};
recordProcess.onNormalExit = function() {
SendNotification.show("Recording stopped", recordProcess.filePath);
};
recordProcess.running = true;
SendNotification.show("Recording started", "Recording to " + recordProcess.filePath);
});
}
Process {
id: recordProcess
property string filePath: ""
property var onNormalExit: null
property var onErrorExit: null
running: false
onExited: function(exitCode, exitStatus) {
if (exitCode === 0) {
Logger.log("RecordService", "Recording stopped successfully.");
if (onNormalExit) {
onNormalExit();
onNormalExit = null;
}
} else {
Logger.error("RecordService", "Recording process exited with error code: " + exitCode);
if (onErrorExit) {
onErrorExit();
onErrorExit = null;
}
}
isRecording = false;
isStopping = false;
recordingDisplay = "";
}
}
Process {
id: niriFocusedOutputProcess
property var onGetName: null
running: false
command: ["niri", "msg", "focused-output"]
onExited: function(exitCode, exitStatus) {
if (exitCode !== 0) {
Logger.error("RecordService", "Failed to get focused output via niri.");
if (niriFocusedOutputProcess.onGetName) {
niriFocusedOutputProcess.onGetName(null);
niriFocusedOutputProcess.onGetName = null;
}
}
}
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (niriFocusedOutputProcess.onGetName) {
const parts = data.split(' ');
const name = parts.length > 0 ? parts[parts.length - 1].slice(1)?.slice(0, -1) : null;
name ? Logger.log("RecordService", "Focused output is: " + name) : Logger.warn("RecordService", "No focused output found.");
niriFocusedOutputProcess.onGetName(name);
niriFocusedOutputProcess.onGetName = null;
}
}
}
}
}

View File

@@ -0,0 +1,16 @@
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
Singleton {
id: root
function show(title, message, icon = "", urgency = "normal") {
if (icon)
Quickshell.execDetached(["notify-send", "-u", urgency, "-i", icon, title, message, "-a", "quickshell"]);
else
Quickshell.execDetached(["notify-send", "-u", urgency, title, message, "-a", "quickshell"]);
}
}

View File

@@ -0,0 +1,39 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Constants
import qs.Services
pragma Singleton
Singleton {
property alias primaryColor: adapter.primaryColor
property alias showLyricsBar: adapter.showLyricsBar
property alias notifications: adapter.notifications
property alias location: adapter.location
property string settingsFilePath: Qt.resolvedUrl("../Assets/Config/Settings.json")
FileView {
id: settingsFile
path: settingsFilePath
watchChanges: true
onFileChanged: reload()
onAdapterUpdated: writeAdapter()
JsonAdapter {
id: adapter
property string primaryColor: "#89b4fa"
property bool showLyricsBar: false
property JsonObject notifications
property string location: "New York"
notifications: JsonObject {
property bool doNotDisturb: false
}
}
}
}

View File

@@ -0,0 +1,392 @@
import Qt.labs.folderlistmodel
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Utils
pragma Singleton
Singleton {
// For Intel coretemp, start averaging all available sensors/cores
id: root
// Public values
property real cpuUsage: 0
property real cpuTemp: 0
property real memGb: 0
property real memPercent: 0
property real diskPercent: 0
property real rxSpeed: 0
property real txSpeed: 0
// Configuration
property int sleepDuration: 3000
property int fasterSleepDuration: 1000
// Internal state for CPU calculation
property var prevCpuStats: null
// Internal state for network speed calculation
// Previous Bytes need to be stored as 'real' as they represent the total of bytes transfered
// since the computer started, so their value will easily overlfow a 32bit int.
property real prevRxBytes: 0
property real prevTxBytes: 0
property real prevTime: 0
// Cpu temperature is the most complex
readonly property var supportedTempCpuSensorNames: ["coretemp", "k10temp", "zenpower"]
property string cpuTempSensorName: ""
property string cpuTempHwmonPath: ""
// For Intel coretemp averaging of all cores/sensors
property var intelTempValues: []
property int intelTempFilesChecked: 0
property int intelTempMaxFiles: 20 // Will test up to temp20_input
// -------------------------------------------------------
// -------------------------------------------------------
// Parse memory info from /proc/meminfo
function parseMemoryInfo(text) {
if (!text)
return ;
const lines = text.split('\n');
let memTotal = 0;
let memAvailable = 0;
for (const line of lines) {
if (line.startsWith('MemTotal:'))
memTotal = parseInt(line.split(/\s+/)[1]) || 0;
else if (line.startsWith('MemAvailable:'))
memAvailable = parseInt(line.split(/\s+/)[1]) || 0;
}
if (memTotal > 0) {
const usageKb = memTotal - memAvailable;
root.memGb = (usageKb / 1e+06).toFixed(1);
root.memPercent = Math.round((usageKb / memTotal) * 100);
}
}
// -------------------------------------------------------
// Calculate CPU usage from /proc/stat
function calculateCpuUsage(text) {
if (!text)
return ;
const lines = text.split('\n');
const cpuLine = lines[0];
// First line is total CPU
if (!cpuLine.startsWith('cpu '))
return ;
const parts = cpuLine.split(/\s+/);
const stats = {
"user": parseInt(parts[1]) || 0,
"nice": parseInt(parts[2]) || 0,
"system": parseInt(parts[3]) || 0,
"idle": parseInt(parts[4]) || 0,
"iowait": parseInt(parts[5]) || 0,
"irq": parseInt(parts[6]) || 0,
"softirq": parseInt(parts[7]) || 0,
"steal": parseInt(parts[8]) || 0,
"guest": parseInt(parts[9]) || 0,
"guestNice": parseInt(parts[10]) || 0
};
const totalIdle = stats.idle + stats.iowait;
const total = Object.values(stats).reduce((sum, val) => {
return sum + val;
}, 0);
if (root.prevCpuStats) {
const prevTotalIdle = root.prevCpuStats.idle + root.prevCpuStats.iowait;
const prevTotal = Object.values(root.prevCpuStats).reduce((sum, val) => {
return sum + val;
}, 0);
const diffTotal = total - prevTotal;
const diffIdle = totalIdle - prevTotalIdle;
if (diffTotal > 0)
root.cpuUsage = (((diffTotal - diffIdle) / diffTotal) * 100).toFixed(1);
}
root.prevCpuStats = stats;
}
// -------------------------------------------------------
// Calculate RX and TX speed from /proc/net/dev
// Average speed of all interfaces excepted 'lo'
function calculateNetworkSpeed(text) {
if (!text)
return ;
const currentTime = Date.now() / 1000;
const lines = text.split('\n');
let totalRx = 0;
let totalTx = 0;
for (var i = 2; i < lines.length; i++) {
const line = lines[i].trim();
if (!line)
continue;
const colonIndex = line.indexOf(':');
if (colonIndex === -1)
continue;
const iface = line.substring(0, colonIndex).trim();
if (iface === 'lo')
continue;
const statsLine = line.substring(colonIndex + 1).trim();
const stats = statsLine.split(/\s+/);
const rxBytes = parseInt(stats[0], 10) || 0;
const txBytes = parseInt(stats[8], 10) || 0;
totalRx += rxBytes;
totalTx += txBytes;
}
// Compute only if we have a previous run to compare to.
if (root.prevTime > 0) {
const timeDiff = currentTime - root.prevTime;
// Avoid division by zero if time hasn't passed.
if (timeDiff > 0) {
let rxDiff = totalRx - root.prevRxBytes;
let txDiff = totalTx - root.prevTxBytes;
// Handle counter resets (e.g., WiFi reconnect), which would cause a negative value.
if (rxDiff < 0)
rxDiff = 0;
if (txDiff < 0)
txDiff = 0;
root.rxSpeed = Math.round(rxDiff / timeDiff); // Speed in Bytes/s
root.txSpeed = Math.round(txDiff / timeDiff);
}
}
root.prevRxBytes = totalRx;
root.prevTxBytes = totalTx;
root.prevTime = currentTime;
}
// -------------------------------------------------------
// Helper function to format network speeds
function formatSpeed(bytesPerSecond) {
if (bytesPerSecond < 1024 * 1024) {
const kb = bytesPerSecond / 1024;
if (kb < 10)
return kb.toFixed(1) + "KB";
else
return Math.round(kb) + "KB";
} else if (bytesPerSecond < 1024 * 1024 * 1024) {
return (bytesPerSecond / (1024 * 1024)).toFixed(1) + "MB";
} else {
return (bytesPerSecond / (1024 * 1024 * 1024)).toFixed(1) + "GB";
}
}
// -------------------------------------------------------
// Compact speed formatter for vertical bar display
function formatCompactSpeed(bytesPerSecond) {
if (!bytesPerSecond || bytesPerSecond <= 0)
return "0";
const units = ["", "K", "M", "G"];
let value = bytesPerSecond;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value = value / 1024;
unitIndex++;
}
// Promote at ~100 of current unit (e.g., 100k -> ~0.1M shown as 0.1M or 0M if rounded)
if (unitIndex < units.length - 1 && value >= 100) {
value = value / 1024;
unitIndex++;
}
const display = Math.round(value).toString();
return display + units[unitIndex];
}
// -------------------------------------------------------
// Function to start fetching and computing the cpu temperature
function updateCpuTemperature() {
// For AMD sensors (k10temp and zenpower), only use Tctl sensor
// temp1_input corresponds to Tctl (Temperature Control) on these sensors
if (root.cpuTempSensorName === "k10temp" || root.cpuTempSensorName === "zenpower") {
cpuTempReader.path = `${root.cpuTempHwmonPath}/temp1_input`;
cpuTempReader.reload();
} else if (root.cpuTempSensorName === "coretemp") {
root.intelTempValues = [];
root.intelTempFilesChecked = 0;
checkNextIntelTemp();
}
}
// -------------------------------------------------------
// Function to check next Intel temperature sensor
function checkNextIntelTemp() {
if (root.intelTempFilesChecked >= root.intelTempMaxFiles) {
// Calculate average of all found temperatures
if (root.intelTempValues.length > 0) {
let sum = 0;
for (var i = 0; i < root.intelTempValues.length; i++) {
sum += root.intelTempValues[i];
}
root.cpuTemp = Math.round(sum / root.intelTempValues.length);
} else {
Logger.warn("SystemStatService", "No temperature sensors found for coretemp");
root.cpuTemp = 0;
}
return ;
}
// Check next temperature file
root.intelTempFilesChecked++;
cpuTempReader.path = `${root.cpuTempHwmonPath}/temp${root.intelTempFilesChecked}_input`;
cpuTempReader.reload();
}
// --------------------------------------------
Component.onCompleted: {
// Kickoff the cpu name detection for temperature
cpuTempNameReader.checkNext();
}
// --------------------------------------------
// Timer for periodic updates
Timer {
id: updateTimer
interval: root.sleepDuration
repeat: true
running: true
triggeredOnStart: true
onTriggered: {
// Trigger all direct system files reads
memInfoFile.reload();
cpuStatFile.reload();
// Run df (disk free) one time
dfProcess.running = true;
updateCpuTemperature();
}
}
Timer {
id: fasterUpdateTimer
interval: root.fasterSleepDuration
repeat: true
running: true
triggeredOnStart: true
onTriggered: {
netDevFile.reload();
}
}
// --------------------------------------------
// FileView components for reading system files
FileView {
id: memInfoFile
path: "/proc/meminfo"
onLoaded: parseMemoryInfo(text())
}
FileView {
id: cpuStatFile
path: "/proc/stat"
onLoaded: calculateCpuUsage(text())
}
FileView {
id: netDevFile
path: "/proc/net/dev"
onLoaded: calculateNetworkSpeed(text())
}
// --------------------------------------------
// Process to fetch disk usage in percent
// Uses 'df' aka 'disk free'
Process {
id: dfProcess
command: ["df", "--output=pcent", "/"]
running: false
stdout: StdioCollector {
onStreamFinished: {
const lines = text.trim().split('\n');
if (lines.length >= 2) {
const percent = lines[1].replace(/[^0-9]/g, '');
root.diskPercent = parseInt(percent) || 0;
}
}
}
}
// --------------------------------------------
// --------------------------------------------
// CPU Temperature
// It's more complex.
// ----
// #1 - Find a common cpu sensor name ie: "coretemp", "k10temp", "zenpower"
FileView {
id: cpuTempNameReader
property int currentIndex: 0
function checkNext() {
if (currentIndex >= 16) {
// Check up to hwmon10
Logger.warn("SystemStatService", "No supported temperature sensor found");
return ;
}
cpuTempNameReader.path = `/sys/class/hwmon/hwmon${currentIndex}/name`;
cpuTempNameReader.reload();
}
printErrors: false
onLoaded: {
const name = text().trim();
if (root.supportedTempCpuSensorNames.includes(name)) {
root.cpuTempSensorName = name;
root.cpuTempHwmonPath = `/sys/class/hwmon/hwmon${currentIndex}`;
Logger.log("SystemStatService", `Found ${root.cpuTempSensorName} CPU thermal sensor at ${root.cpuTempHwmonPath}`);
} else {
currentIndex++;
Qt.callLater(() => {
// Qt.callLater is mandatory
checkNext();
});
}
}
onLoadFailed: function(error) {
currentIndex++;
Qt.callLater(() => {
// Qt.callLater is mandatory
checkNext();
});
}
}
// ----
// #2 - Read sensor value
FileView {
id: cpuTempReader
printErrors: false
onLoaded: {
const data = text().trim();
if (root.cpuTempSensorName === "coretemp") {
// For Intel, collect all temperature values
const temp = parseInt(data) / 1000;
root.intelTempValues.push(temp);
Qt.callLater(() => {
// Qt.callLater is mandatory
checkNextIntelTemp();
});
} else {
// For AMD sensors (k10temp and zenpower), directly set the temperature
root.cpuTemp = Math.round(parseInt(data) / 1000);
}
}
onLoadFailed: function(error) {
Qt.callLater(() => {
// Qt.callLater is mandatory
checkNextIntelTemp();
});
}
}
}

View File

@@ -0,0 +1,46 @@
import QtQuick
import Quickshell
pragma Singleton
Singleton {
// ignore and fall back
id: root
function iconFromName(iconName, fallbackName) {
const fallback = fallbackName || "application-x-executable";
try {
if (iconName && typeof Quickshell !== 'undefined' && Quickshell.iconPath) {
const p = Quickshell.iconPath(iconName, fallback);
if (p && p !== "")
return p;
}
} catch (e) {
}
try {
return Quickshell.iconPath ? (Quickshell.iconPath(fallback, true) || "") : "";
} catch (e2) {
return "";
}
}
// Resolve icon path for a DesktopEntries appId - safe on missing entries
function iconForAppId(appId, fallbackName) {
const fallback = fallbackName || "application-x-executable";
if (!appId)
return iconFromName(fallback, fallback);
try {
if (typeof DesktopEntries === 'undefined' || !DesktopEntries.byId)
return iconFromName(fallback, fallback);
const entry = (DesktopEntries.heuristicLookup) ? DesktopEntries.heuristicLookup(appId) : DesktopEntries.byId(appId);
const name = entry && entry.icon ? entry.icon : "";
return iconFromName(name || fallback, fallback);
} catch (e) {
return iconFromName(fallback, fallback);
}
}
}

View File

@@ -0,0 +1,44 @@
import QtQuick
import Quickshell
pragma Singleton
Singleton {
id: root
property var date: new Date()
property string time: Qt.formatDateTime(date, "HH:mm")
property string dateString: {
let now = date;
let dayName = now.toLocaleDateString(Qt.locale(), "ddd");
dayName = dayName.charAt(0).toUpperCase() + dayName.slice(1);
let day = now.getDate();
let suffix;
if (day > 3 && day < 21)
suffix = 'th';
else
switch (day % 10) {
case 1:
suffix = "st";
break;
case 2:
suffix = "nd";
break;
case 3:
suffix = "rd";
break;
default:
suffix = "th";
};
let month = now.toLocaleDateString(Qt.locale(), "MMMM");
let year = now.toLocaleDateString(Qt.locale(), "yyyy");
return `${dayName}, ` + `${day}${suffix} ${month} ${year}`;
}
Timer {
interval: 1000
repeat: true
running: true
onTriggered: root.date = new Date()
}
}

View File

@@ -0,0 +1,56 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
import qs.Utils
Singleton {
id: root
property ListModel workspaces
workspaces: ListModel {
}
function initNiri() {
updateNiriWorkspaces();
}
function updateNiriWorkspaces() {
const niriWorkspaces = Niri.workspaces || [];
workspaces.clear();
for (let i = 0; i < niriWorkspaces.length; i++) {
const ws = niriWorkspaces[i];
workspaces.append({
"id": ws.id,
"idx": ws.idx || 1,
"name": ws.name || "",
"output": ws.output || "",
"isFocused": ws.isFocused === true,
"isActive": ws.isActive === true,
"isUrgent": ws.isUrgent === true
});
}
workspacesChanged();
}
function switchToWorkspace(workspaceId) {
try {
Quickshell.execDetached(["niri", "msg", "action", "focus-workspace", workspaceId.toString()]);
} catch (e) {
Logger.error("WorkspaceManager", "Error switching Niri workspace:", e);
}
}
Connections {
function onWorkspacesChanged() {
updateNiriWorkspaces();
}
target: Niri
}
}

View File

@@ -0,0 +1,20 @@
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
Singleton {
id: root
function write(text) {
action.command = ["sh", "-c", `echo ${text} | wl-copy -n`];
action.startDetached();
}
Process {
id: action
running: false
}
}