add noctalia and fuzzel

This commit is contained in:
2026-05-23 21:20:58 +02:00
parent 364801f1a3
commit 9a3eceb3ab
599 changed files with 204318 additions and 0 deletions
@@ -0,0 +1,665 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Control
import qs.Services.UI
Singleton {
id: root
// Compositor detection
property bool isHyprland: false
property bool isNiri: false
property bool isSway: false
property bool isMango: false
property bool isLabwc: false
property bool isExtWorkspace: false
property bool isScroll: false
// Generic workspace and window data
property ListModel workspaces: ListModel {}
property ListModel windows: ListModel {}
property int focusedWindowIndex: -1
// Display scale data
property var displayScales: ({})
property bool displayScalesLoaded: false
// Overview state (Niri-specific, defaults to false for other compositors)
property bool overviewActive: false
// Global workspaces flag (workspaces shared across all outputs)
// True for LabWC (stacking compositor), false for tiling WMs with per-output workspaces
property bool globalWorkspaces: false
// Generic events
signal workspaceChanged
signal activeWindowChanged
signal windowListChanged
// Backend service loader
property var backend: null
Component.onCompleted: {
// Load display scales from ShellState
Qt.callLater(() => {
if (typeof ShellState !== 'undefined' && ShellState.isLoaded) {
loadDisplayScalesFromState();
}
});
detectCompositor();
}
Connections {
target: typeof ShellState !== 'undefined' ? ShellState : null
function onIsLoadedChanged() {
if (ShellState.isLoaded) {
loadDisplayScalesFromState();
}
}
}
function detectCompositor() {
const hyprlandSignature = Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE");
const niriSocket = Quickshell.env("NIRI_SOCKET");
const swaySock = Quickshell.env("SWAYSOCK");
const currentDesktop = Quickshell.env("XDG_CURRENT_DESKTOP");
const labwcPid = Quickshell.env("LABWC_PID");
// Check for MangoWC using XDG_CURRENT_DESKTOP environment variable
// MangoWC sets XDG_CURRENT_DESKTOP=mango
if (currentDesktop && currentDesktop.toLowerCase().includes("mango")) {
isHyprland = false;
isNiri = false;
isSway = false;
isMango = true;
isLabwc = false;
isExtWorkspace = false;
backendLoader.sourceComponent = mangoComponent;
} else if (labwcPid && labwcPid.length > 0) {
isHyprland = false;
isNiri = false;
isSway = false;
isMango = false;
isLabwc = true;
isExtWorkspace = false;
backendLoader.sourceComponent = labwcComponent;
Logger.i("CompositorService", "Detected LabWC with PID: " + labwcPid);
} else if (niriSocket && niriSocket.length > 0) {
isHyprland = false;
isNiri = true;
isSway = false;
isMango = false;
isLabwc = false;
isExtWorkspace = false;
backendLoader.sourceComponent = niriComponent;
} else if (hyprlandSignature && hyprlandSignature.length > 0) {
isHyprland = true;
isNiri = false;
isSway = false;
isMango = false;
isLabwc = false;
isExtWorkspace = false;
backendLoader.sourceComponent = hyprlandComponent;
} else if (swaySock && swaySock.length > 0) {
isHyprland = false;
isNiri = false;
isSway = true;
isMango = false;
isLabwc = false;
isExtWorkspace = false;
isScroll = currentDesktop && currentDesktop.toLowerCase().includes("scroll");
backendLoader.sourceComponent = swayComponent;
} else {
// Always fallback to ext-workspace-v1
isHyprland = false;
isNiri = false;
isSway = false;
isMango = false;
isLabwc = false;
isExtWorkspace = true;
backendLoader.sourceComponent = extWorkspaceComponent;
Logger.i("CompositorService", "Using generic ext-workspace backend (no recognized compositor env)");
}
}
Loader {
id: backendLoader
onLoaded: {
if (item) {
if (isScroll) {
item.msgCommand = "scrollmsg";
}
root.backend = item;
setupBackendConnections();
backend.initialize();
}
}
}
// Load display scales from ShellState
function loadDisplayScalesFromState() {
try {
const cached = ShellState.getDisplay();
if (cached && Object.keys(cached).length > 0) {
displayScales = cached;
displayScalesLoaded = true;
Logger.d("CompositorService", "Loaded display scales from ShellState");
} else {
// Migration is now handled in Settings.qml
displayScalesLoaded = true;
}
} catch (error) {
Logger.e("CompositorService", "Failed to load display scales:", error);
displayScalesLoaded = true;
}
}
// Hyprland backend component
Component {
id: hyprlandComponent
HyprlandService {
id: hyprlandBackend
}
}
// Niri backend component
Component {
id: niriComponent
NiriService {
id: niriBackend
}
}
// Sway backend component
Component {
id: swayComponent
SwayService {
id: swayBackend
}
}
// Mango backend component
Component {
id: mangoComponent
MangoService {
id: mangoBackend
}
}
// Labwc backend component
Component {
id: labwcComponent
LabwcService {
id: labwcBackend
}
}
// Generic ext-workspace (WindowManager) when compositor env is unknown
Component {
id: extWorkspaceComponent
ExtWorkspaceService {
id: extWorkspaceBackend
}
}
function setupBackendConnections() {
if (!backend)
return;
// Connect backend signals to facade signals
backend.workspaceChanged.connect(() => {
// Sync workspaces when they change
syncWorkspaces();
// Forward the signal
workspaceChanged();
});
backend.activeWindowChanged.connect(() => {
// Only sync focus state, not entire window list
syncFocusedWindow();
// Forward the signal
activeWindowChanged();
});
backend.windowListChanged.connect(() => {
syncWindows();
});
// Property bindings - use automatic property change signal
backend.focusedWindowIndexChanged.connect(() => {
focusedWindowIndex = backend.focusedWindowIndex;
});
// Overview state (Niri-specific)
if (backend.overviewActiveChanged) {
backend.overviewActiveChanged.connect(() => {
overviewActive = backend.overviewActive;
});
}
// Initial sync
syncWorkspaces();
syncWindows();
focusedWindowIndex = backend.focusedWindowIndex;
if (backend.overviewActive !== undefined) {
overviewActive = backend.overviewActive;
}
if (backend.globalWorkspaces !== undefined) {
globalWorkspaces = backend.globalWorkspaces;
}
}
function syncWorkspaces() {
workspaces.clear();
const ws = backend.workspaces;
for (var i = 0; i < ws.count; i++) {
workspaces.append(ws.get(i));
}
// Emit signal to notify listeners that workspace list has been updated
workspacesChanged();
}
function syncWindows() {
windows.clear();
const ws = backend.windows;
for (var i = 0; i < ws.length; i++) {
windows.append(ws[i]);
}
// Emit signal to notify listeners that window list has been updated
windowListChanged();
}
// Sync only the focused window state, not the entire window list
function syncFocusedWindow() {
const newIndex = backend.focusedWindowIndex;
// Update isFocused flags by syncing from backend
for (var i = 0; i < windows.count && i < backend.windows.length; i++) {
const backendFocused = backend.windows[i].isFocused;
if (windows.get(i).isFocused !== backendFocused) {
windows.setProperty(i, "isFocused", backendFocused);
}
}
focusedWindowIndex = newIndex;
}
// Update display scales from backend
function updateDisplayScales() {
if (!backend || !backend.queryDisplayScales) {
Logger.w("CompositorService", "Backend does not support display scale queries");
return;
}
backend.queryDisplayScales();
}
// Called by backend when display scales are ready
function onDisplayScalesUpdated(scales) {
displayScales = scales;
saveDisplayScalesToCache();
Logger.d("CompositorService", "Display scales updated");
}
// Save display scales to cache
function saveDisplayScalesToCache() {
try {
ShellState.setDisplay(displayScales);
Logger.d("CompositorService", "Saved display scales to ShellState");
} catch (error) {
Logger.e("CompositorService", "Failed to save display scales:", error);
}
}
// Public function to get scale for a specific display
function getDisplayScale(displayName) {
if (!displayName || !displayScales[displayName]) {
return 1.0;
}
return displayScales[displayName].scale || 1.0;
}
// Public function to get all display info for a specific display
function getDisplayInfo(displayName) {
if (!displayName || !displayScales[displayName]) {
return null;
}
return displayScales[displayName];
}
// Get focused window
function getFocusedWindow() {
if (focusedWindowIndex >= 0 && focusedWindowIndex < windows.count) {
return windows.get(focusedWindowIndex);
}
return null;
}
// Get focused screen from compositor
function getFocusedScreen() {
if (backend && backend.getFocusedScreen) {
return backend.getFocusedScreen();
}
return null;
}
// Get focused window title
function getFocusedWindowTitle() {
if (focusedWindowIndex >= 0 && focusedWindowIndex < windows.count) {
var title = windows.get(focusedWindowIndex).title;
if (title !== undefined) {
title = title.replace(/(\r\n|\n|\r)/g, "");
}
return title || "";
}
return "";
}
// Get clean app name from appId
// Extracts the last segment from reverse domain notation (e.g., "org.kde.dolphin" -> "Dolphin")
// Falls back to title if appId is empty
function getCleanAppName(appId, fallbackTitle) {
var name = (appId || "").split(".").pop() || fallbackTitle || "Unknown";
return name.charAt(0).toUpperCase() + name.slice(1);
}
function getWindowsForWorkspace(workspaceId) {
var windowsInWs = [];
for (var i = 0; i < windows.count; i++) {
var window = windows.get(i);
if (window.workspaceId === workspaceId) {
// Snapshot to plain JS object so callers never hold live ListModel
// proxies that become invalid when syncWindows() clears the model.
windowsInWs.push({
id: window.id,
title: window.title,
appId: window.appId,
isFocused: window.isFocused,
workspaceId: window.workspaceId,
handle: window.handle
});
}
}
return windowsInWs;
}
// Generic workspace switching
function switchToWorkspace(workspace) {
if (backend && backend.switchToWorkspace) {
backend.switchToWorkspace(workspace);
} else {
Logger.w("Compositor", "No backend available for workspace switching");
}
}
// Scrollable workspace content (Niri)
function scrollWorkspaceContent(direction) {
if (backend && backend.scrollWorkspaceContent) {
backend.scrollWorkspaceContent(direction);
}
}
// Get current workspace
function getCurrentWorkspace() {
for (var i = 0; i < workspaces.count; i++) {
const ws = workspaces.get(i);
if (ws.isFocused) {
return ws;
}
}
return null;
}
// Get active workspaces
function getActiveWorkspaces() {
const activeWorkspaces = [];
for (var i = 0; i < workspaces.count; i++) {
const ws = workspaces.get(i);
if (ws.isActive) {
activeWorkspaces.push(ws);
}
}
return activeWorkspaces;
}
// Set focused window
function focusWindow(window) {
if (backend && backend.focusWindow) {
backend.focusWindow(window);
} else {
Logger.w("Compositor", "No backend available for window focus");
}
}
// Close window
function closeWindow(window) {
if (backend && backend.closeWindow) {
backend.closeWindow(window);
} else {
Logger.w("Compositor", "No backend available for window closing");
}
}
// Spawn command
function spawn(command) {
// Ensure command is a proper JS array (QML lists can behave unexpectedly in some contexts)
const cmdArray = Array.isArray(command) ? command : (command && typeof command === "object" && command.length !== undefined) ? Array.from(command) : [command];
Logger.d("CompositorService", `Spawning: ${cmdArray.join(" ")}`);
if (backend && backend.spawn) {
backend.spawn(cmdArray);
} else {
try {
Quickshell.execDetached(cmdArray);
} catch (e) {
Logger.e("CompositorService", "Failed to execute detached:", e);
}
}
}
// Session management helper for custom commands
function getCustomCommand(action) {
const powerOptions = Settings.data.sessionMenu.powerOptions || [];
for (let i = 0; i < powerOptions.length; i++) {
const option = powerOptions[i];
if (option.action === action && option.enabled && option.command && option.command.trim() !== "") {
return option.command.trim();
}
}
return "";
}
function executeSessionAction(action, defaultCommand) {
const customCommand = getCustomCommand(action);
if (customCommand) {
Logger.i("Compositor", `Executing custom command for action: ${action} Command: ${customCommand}`);
Quickshell.execDetached(["sh", "-c", customCommand]);
return true;
}
return false;
}
// Session management
function logout() {
Logger.i("Compositor", "Logout requested");
if (executeSessionAction("logout"))
return;
if (backend && backend.logout) {
backend.logout();
} else {
Logger.w("Compositor", "No backend available for logout");
}
}
function shutdown() {
Logger.i("Compositor", "Shutdown requested");
if (executeSessionAction("shutdown"))
return;
HooksService.executeSessionHook("shutdown", () => {
Quickshell.execDetached(["sh", "-c", "systemctl poweroff || loginctl poweroff"]);
});
}
function reboot() {
Logger.i("Compositor", "Reboot requested");
if (executeSessionAction("reboot"))
return;
HooksService.executeSessionHook("reboot", () => {
Quickshell.execDetached(["sh", "-c", "systemctl reboot || loginctl reboot"]);
});
}
function userspaceReboot() {
Logger.i("Compositor", "Userspace reboot requested");
if (executeSessionAction("userspaceReboot"))
return;
HooksService.executeSessionHook("userspaceReboot", () => {
Quickshell.execDetached(["sh", "-c", "systemctl soft-reboot"]);
});
}
function rebootToUefi() {
Logger.i("Compositor", "Reboot to UEFI firmware requested requested");
if (executeSessionAction("rebootToUefi"))
return;
HooksService.executeSessionHook("rebootToUefi", () => {
Quickshell.execDetached(["sh", "-c", "systemctl reboot --firmware-setup || loginctl reboot --firmware-setup"]);
});
}
function turnOffMonitors() {
Logger.i("Compositor", "Turn off monitors requested");
if (backend && backend.turnOffMonitors) {
backend.turnOffMonitors();
} else {
Logger.w("Compositor", "No backend available for turnOffMonitors");
}
}
function turnOnMonitors() {
Logger.i("Compositor", "Turn on monitors requested");
if (backend && backend.turnOnMonitors) {
backend.turnOnMonitors();
} else {
Logger.w("Compositor", "No backend available for turnOnMonitors");
}
}
function suspend() {
Logger.i("Compositor", "Suspend requested");
if (executeSessionAction("suspend"))
return;
Quickshell.execDetached(["sh", "-c", "systemctl suspend || loginctl suspend"]);
}
function lock() {
Logger.i("Compositor", "LockScreen requested");
if (executeSessionAction("lock"))
return;
if (PanelService && PanelService.lockScreen) {
PanelService.lockScreen.active = true;
}
}
function hibernate() {
Logger.i("Compositor", "Hibernate requested");
if (executeSessionAction("hibernate"))
return;
Quickshell.execDetached(["sh", "-c", "systemctl hibernate || loginctl hibernate"]);
}
function cycleKeyboardLayout() {
if (backend && backend.cycleKeyboardLayout) {
backend.cycleKeyboardLayout();
}
}
property int lockAndSuspendCheckCount: 0
function lockAndSuspend() {
Logger.i("Compositor", "Lock and suspend requested");
// if a custom lock command exists, execute it and suspend without wait
if (executeSessionAction("lock")) {
suspend();
return;
}
// If already locked, suspend immediately
if (PanelService && PanelService.lockScreen && PanelService.lockScreen.active) {
Logger.i("Compositor", "Screen already locked, suspending");
suspend();
return;
}
// Lock the screen first
try {
if (PanelService && PanelService.lockScreen) {
PanelService.lockScreen.active = true;
lockAndSuspendCheckCount = 0;
// Wait for lock screen to be confirmed active before suspending
lockAndSuspendTimer.start();
} else {
Logger.w("Compositor", "Lock screen not available, suspending without lock");
suspend();
}
} catch (e) {
Logger.w("Compositor", "Failed to activate lock screen before suspend: " + e);
suspend();
}
}
Timer {
id: lockAndSuspendTimer
interval: 100
repeat: true
running: false
onTriggered: {
lockAndSuspendCheckCount++;
// Check if lock screen is now active
if (PanelService && PanelService.lockScreen && PanelService.lockScreen.active) {
// Verify the lock screen component is loaded
if (PanelService.lockScreen.item) {
Logger.i("Compositor", "Lock screen confirmed active, suspending");
stop();
lockAndSuspendCheckCount = 0;
suspend();
} else {
// Lock screen is active but component not loaded yet, wait a bit more
if (lockAndSuspendCheckCount > 20) {
// Max 2 seconds wait
Logger.w("Compositor", "Lock screen active but component not loaded, suspending anyway");
stop();
lockAndSuspendCheckCount = 0;
suspend();
}
}
} else {
// Lock screen not active yet, keep checking
if (lockAndSuspendCheckCount > 30) {
// Max 3 seconds wait
Logger.w("Compositor", "Lock screen failed to activate, suspending anyway");
stop();
lockAndSuspendCheckCount = 0;
suspend();
}
}
}
}
}
@@ -0,0 +1,300 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.WindowManager
import qs.Commons
// Generic ext-workspace-v1 + toplevel handling
Item {
id: root
property ListModel workspaces: ListModel {}
property var windows: []
property int focusedWindowIndex: -1
property var trackedToplevels: new Set()
property bool globalWorkspaces: false
property var nativeWorkspaceMap: ({})
property var connectedWorkspaces: ({})
signal workspaceChanged
signal activeWindowChanged
signal windowListChanged
signal displayScalesChanged
function initialize() {
updateWindows();
connectWorkspaceSignals();
syncWorkspaces();
Logger.i("ExtWorkspaceService", "Service started (generic ext-workspace-v1)");
}
Connections {
target: WindowManager
function onWindowsetsChanged() {
root.connectWorkspaceSignals();
Qt.callLater(root.syncWorkspaces);
}
function onWindowsetProjectionsChanged() {
Qt.callLater(root.syncWorkspaces);
}
}
Timer {
interval: 500
running: true
repeat: false
onTriggered: {
if (WindowManager.windowsets.length > 0) {
root.connectWorkspaceSignals();
root.syncWorkspaces();
}
}
}
function connectWorkspaceSignals() {
const nativeWs = WindowManager.windowsets;
const newConnected = {};
for (const ws of nativeWs) {
const key = ws.id || ws.toString();
newConnected[key] = true;
if (connectedWorkspaces[key])
continue;
ws.activeChanged.connect(() => {
Qt.callLater(root.syncWorkspaces);
});
ws.urgentChanged.connect(() => {
Qt.callLater(root.syncWorkspaces);
});
ws.shouldDisplayChanged.connect(() => {
Qt.callLater(root.syncWorkspaces);
});
ws.nameChanged.connect(() => {
Qt.callLater(root.syncWorkspaces);
});
}
connectedWorkspaces = newConnected;
}
function syncWorkspaces() {
const nativeWs = WindowManager.windowsets;
workspaces.clear();
nativeWorkspaceMap = {};
/* Per-output (projection) index: compositors expose one workspace group per
* monitor with names "1"…"9". A single global idx produced 1018 on the
* second head because all windowsets were merged into one list. */
const perOutputNextIdx = {};
for (const ws of nativeWs) {
if (!ws.shouldDisplay) {
continue;
}
let outputName = "";
if (ws.projection) {
const projScreens = ws.projection.screens;
if (projScreens && projScreens.length > 0) {
outputName = projScreens[0].name || "";
}
}
const groupKey = outputName || "_";
let idx;
const numericName = ws.name && /^\d+$/.test(String(ws.name)) ? parseInt(ws.name, 10) : NaN;
if (!isNaN(numericName) && numericName >= 1) {
idx = numericName;
} else {
if (perOutputNextIdx[groupKey] === undefined) {
perOutputNextIdx[groupKey] = 1;
}
idx = perOutputNextIdx[groupKey]++;
}
const wsEntry = {
"id": ws.id || idx.toString(),
"idx": idx,
"name": ws.name || ("Workspace " + idx),
"output": outputName,
"isFocused": ws.active,
"isActive": true,
"isUrgent": ws.urgent,
"isOccupied": false,
"oid": ws.id || idx.toString()
};
workspaces.append(wsEntry);
nativeWorkspaceMap[wsEntry.id] = ws;
}
updateWindowWorkspaces();
workspaceChanged();
}
function updateWindowWorkspaces() {
let activeId = "";
for (let i = 0; i < workspaces.count; i++) {
const ws = workspaces.get(i);
if (ws.isFocused) {
activeId = ws.id;
break;
}
}
for (let i = 0; i < windows.length; i++) {
if (activeId) {
windows[i].workspaceId = activeId;
}
}
windowListChanged();
}
Connections {
target: ToplevelManager.toplevels
function onValuesChanged() {
updateWindows();
}
}
function connectToToplevel(toplevel) {
if (!toplevel)
return;
toplevel.activatedChanged.connect(() => {
Qt.callLater(onToplevelActivationChanged);
});
toplevel.titleChanged.connect(() => {
Qt.callLater(updateWindows);
});
}
function onToplevelActivationChanged() {
updateWindows();
activeWindowChanged();
}
function updateWindows() {
const newWindows = [];
const toplevels = ToplevelManager.toplevels?.values || [];
let focusedIdx = -1;
let idx = 0;
let activeId = "";
for (let i = 0; i < workspaces.count; i++) {
const ws = workspaces.get(i);
if (ws.isFocused) {
activeId = ws.id;
break;
}
}
for (const toplevel of toplevels) {
if (!toplevel)
continue;
if (!trackedToplevels.has(toplevel)) {
connectToToplevel(toplevel);
trackedToplevels.add(toplevel);
}
const output = (toplevel.screens && toplevel.screens.length > 0) ? (toplevel.screens[0].name || "") : "";
const windowId = (toplevel.appId || "") + ":" + idx;
newWindows.push({
"id": windowId,
"appId": toplevel.appId || "",
"title": toplevel.title || "",
"output": output,
"workspaceId": activeId || "1",
"isFocused": toplevel.activated || false,
"toplevel": toplevel
});
if (toplevel.activated) {
focusedIdx = idx;
}
idx++;
}
windows = newWindows;
focusedWindowIndex = focusedIdx;
windowListChanged();
}
function focusWindow(window) {
if (window.toplevel && typeof window.toplevel.activate === "function") {
window.toplevel.activate();
}
}
function closeWindow(window) {
if (window.toplevel && typeof window.toplevel.close === "function") {
window.toplevel.close();
}
}
function switchToWorkspace(workspace) {
const nativeWs = nativeWorkspaceMap[workspace.id] || nativeWorkspaceMap[workspace.oid];
if (nativeWs && nativeWs.canActivate) {
nativeWs.activate();
} else {
Logger.w("ExtWorkspaceService", "Cannot activate workspace: " + (workspace.name || workspace.id));
}
}
function turnOffMonitors() {
try {
Quickshell.execDetached(["wlr-randr", "--off"]);
} catch (e) {
Logger.e("ExtWorkspaceService", "Failed to turn off monitors:", e);
}
}
function turnOnMonitors() {
try {
Quickshell.execDetached(["wlr-randr", "--on"]);
} catch (e) {
Logger.e("ExtWorkspaceService", "Failed to turn on monitors:", e);
}
}
function logout() {
const sid = Quickshell.env("XDG_SESSION_ID");
try {
if (sid && sid.length > 0) {
Quickshell.execDetached(["loginctl", "terminate-session", sid]);
} else {
Logger.w("ExtWorkspaceService", "logout: XDG_SESSION_ID unset; use session menu custom command or compositor-specific backend");
}
} catch (e) {
Logger.e("ExtWorkspaceService", "Failed to logout:", e);
}
}
function cycleKeyboardLayout() {
Logger.w("ExtWorkspaceService", "Keyboard layout cycling not supported");
}
function queryDisplayScales() {
Logger.w("ExtWorkspaceService", "Display scale queries not supported via ToplevelManager");
}
function getFocusedScreen() {
return null;
}
}
@@ -0,0 +1,615 @@
import QtQuick
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import qs.Commons
import qs.Services.Keyboard
Item {
id: root
// Properties that match the facade interface
property ListModel workspaces: ListModel {}
property var windows: []
property int focusedWindowIndex: -1
// Signals that match the facade interface
signal workspaceChanged
signal activeWindowChanged
signal windowListChanged
signal displayScalesChanged
// Hyprland-specific properties
property bool initialized: false
property var workspaceCache: ({})
property var windowCache: ({})
// Debounce timer for window updates
Timer {
id: updateTimer
interval: 50
repeat: false
onTriggered: safeUpdate()
}
// Deferred via Qt.callLater to coalesce workspace updates: onRawEvent calls
// refreshWorkspaces() which triggers onValuesChanged synchronously in the
// same call stack — without deferral the ListModel gets cleared+repopulated
// twice per event. Qt.callLater deduplicates by function identity.
function _deferredWorkspaceUpdate() {
safeUpdateWorkspaces();
workspaceChanged();
}
// Initialization
function initialize() {
if (initialized)
return;
try {
Hyprland.refreshWorkspaces();
Hyprland.refreshToplevels();
Qt.callLater(() => {
safeUpdateWorkspaces();
safeUpdateWindows();
queryDisplayScales();
queryKeyboardLayout();
});
initialized = true;
Logger.i("HyprlandService", "Service started");
} catch (e) {
Logger.e("HyprlandService", "Failed to initialize:", e);
}
}
// Query display scales
function queryDisplayScales() {
hyprlandMonitorsProcess.running = true;
}
// Hyprland monitors process for display scale detection
Process {
id: hyprlandMonitorsProcess
running: false
command: ["hyprctl", "monitors", "-j"]
property string accumulatedOutput: ""
stdout: SplitParser {
onRead: function (line) {
// Accumulate lines instead of parsing each one
hyprlandMonitorsProcess.accumulatedOutput += line;
}
}
onExited: function (exitCode) {
if (exitCode !== 0 || !accumulatedOutput) {
Logger.e("HyprlandService", "Failed to query monitors, exit code:", exitCode);
accumulatedOutput = "";
return;
}
try {
const monitorsData = JSON.parse(accumulatedOutput);
const scales = {};
for (const monitor of monitorsData) {
if (monitor.name) {
scales[monitor.name] = {
"name": monitor.name,
"scale": monitor.scale || 1.0,
"width": monitor.width || 0,
"height": monitor.height || 0,
"refresh_rate": monitor.refreshRate || 0,
"x": monitor.x || 0,
"y": monitor.y || 0,
"active_workspace": monitor.activeWorkspace ? monitor.activeWorkspace.id : -1,
"vrr": monitor.vrr || false,
"focused": monitor.focused || false
};
}
}
// Notify CompositorService (it will emit displayScalesChanged)
if (CompositorService && CompositorService.onDisplayScalesUpdated) {
CompositorService.onDisplayScalesUpdated(scales);
}
} catch (e) {
Logger.e("HyprlandService", "Failed to parse monitors:", e);
} finally {
// Clear accumulated output for next query
accumulatedOutput = "";
}
}
}
function queryKeyboardLayout() {
hyprlandDevicesProcess.running = true;
}
// Hyprland devices process for keyboard layout detection
Process {
id: hyprlandDevicesProcess
running: false
command: ["hyprctl", "devices", "-j"]
property string accumulatedOutput: ""
stdout: SplitParser {
onRead: function (line) {
// Accumulate lines instead of parsing each one
hyprlandDevicesProcess.accumulatedOutput += line;
}
}
onExited: function (exitCode) {
if (exitCode !== 0 || !accumulatedOutput) {
Logger.e("HyprlandService", "Failed to query devices, exit code:", exitCode);
accumulatedOutput = "";
return;
}
try {
const devicesData = JSON.parse(accumulatedOutput);
for (const keyboard of devicesData.keyboards) {
if (keyboard.main) {
const layoutName = keyboard.active_keymap;
KeyboardLayoutService.setCurrentLayout(layoutName);
Logger.d("HyprlandService", "Keyboard layout switched:", layoutName);
}
}
} catch (e) {
Logger.e("HyprlandService", "Failed to parse devices:", e);
} finally {
// Clear accumulated output for next query
accumulatedOutput = "";
}
}
}
// Safe update wrapper
function safeUpdate() {
safeUpdateWindows();
safeUpdateWorkspaces();
workspaceChanged();
windowListChanged();
}
// Safe workspace update
function safeUpdateWorkspaces() {
try {
workspaces.clear();
workspaceCache = {};
if (!Hyprland.workspaces || !Hyprland.workspaces.values) {
return;
}
const hlWorkspaces = Hyprland.workspaces.values;
const occupiedIds = getOccupiedWorkspaceIds();
for (var i = 0; i < hlWorkspaces.length; i++) {
const ws = hlWorkspaces[i];
if (ws.name && ws.name.startsWith("special:"))
continue;
const wsData = {
"id": ws.id,
"idx": ws.id,
"name": ws.name || "",
"output": (ws.monitor && ws.monitor.name) ? ws.monitor.name : "",
"isActive": ws.active === true,
"isFocused": ws.focused === true,
"isUrgent": ws.urgent === true,
"isOccupied": occupiedIds[ws.id] === true
};
workspaceCache[ws.id] = wsData;
workspaces.append(wsData);
}
} catch (e) {
Logger.e("HyprlandService", "Error updating workspaces:", e);
}
}
// Get occupied workspace IDs safely
function getOccupiedWorkspaceIds() {
const occupiedIds = {};
try {
if (!Hyprland.toplevels || !Hyprland.toplevels.values) {
return occupiedIds;
}
const hlToplevels = Hyprland.toplevels.values;
for (var i = 0; i < hlToplevels.length; i++) {
const toplevel = hlToplevels[i];
if (!toplevel)
continue;
try {
const wsId = toplevel.workspace ? toplevel.workspace.id : null;
if (wsId !== null && wsId !== undefined) {
occupiedIds[wsId] = true;
}
} catch (e)
// Ignore individual toplevel errors
{}
}
} catch (e)
// Return empty if we can't determine occupancy
{}
return occupiedIds;
}
// Safe window update
function safeUpdateWindows() {
try {
const windowsList = [];
windowCache = {};
if (!Hyprland.toplevels || !Hyprland.toplevels.values) {
windows = [];
focusedWindowIndex = -1;
return;
}
const hlToplevels = Hyprland.toplevels.values;
let focusedWindowId = null;
// Get active workspaces to filter focus
const activeWorkspaceIds = {};
if (Hyprland.workspaces && Hyprland.workspaces.values) {
const hlWorkspaces = Hyprland.workspaces.values;
for (var j = 0; j < hlWorkspaces.length; j++) {
if (hlWorkspaces[j].active) {
activeWorkspaceIds[hlWorkspaces[j].id] = true;
}
}
}
for (var i = 0; i < hlToplevels.length; i++) {
const toplevel = hlToplevels[i];
if (!toplevel)
continue;
const windowData = extractWindowData(toplevel);
if (windowData) {
// If the window claims to be focused, verify it's on an active workspace
if (windowData.isFocused) {
if (!activeWorkspaceIds[windowData.workspaceId]) {
windowData.isFocused = false;
}
}
// Normalize to a plain, backend-independent window object
const normalized = {
"id": windowData.id ? String(windowData.id) : "",
"title": windowData.title ? String(windowData.title) : "",
"appId": windowData.appId ? String(windowData.appId) : "",
"workspaceId": (typeof windowData.workspaceId === "number" && !isNaN(windowData.workspaceId)) ? windowData.workspaceId : -1,
"isFocused": windowData.isFocused === true,
"output": windowData.output ? String(windowData.output) : "",
"x": (typeof windowData.x === "number" && !isNaN(windowData.x)) ? windowData.x : 0,
"y": (typeof windowData.y === "number" && !isNaN(windowData.y)) ? windowData.y : 0
};
windowsList.push(normalized);
windowCache[normalized.id] = normalized;
if (normalized.isFocused) {
focusedWindowId = normalized.id;
}
}
}
windows = toSortedWindowList(windowsList);
// Resolve focused index from sorted list (order changes after sort)
let newFocusedIndex = -1;
if (focusedWindowId) {
for (let k = 0; k < windows.length; k++) {
if (windows[k].id === focusedWindowId) {
newFocusedIndex = k;
break;
}
}
}
if (newFocusedIndex !== focusedWindowIndex) {
focusedWindowIndex = newFocusedIndex;
activeWindowChanged();
}
} catch (e) {
Logger.e("HyprlandService", "Error updating windows:", e);
}
}
// Extract window data safely from a toplevel
function extractWindowData(toplevel) {
if (!toplevel)
return null;
try {
// Safely extract properties
const windowId = safeGetProperty(toplevel, "address", "");
if (!windowId)
return null;
const appId = getAppId(toplevel);
const title = getAppTitle(toplevel);
const wsId = toplevel.workspace ? toplevel.workspace.id : null;
const focused = toplevel.activated === true;
const output = toplevel.monitor?.name || "";
// Extract position
let x = 0;
let y = 0;
try {
const ipcData = toplevel.lastIpcObject;
if (ipcData && ipcData.at) {
x = ipcData.at[0];
y = ipcData.at[1];
} else if (typeof toplevel.x !== 'undefined') {
x = toplevel.x;
y = toplevel.y;
}
} catch (e) {}
// Normalize coordinates to safe numeric values
const safeX = (typeof x === "number" && !isNaN(x)) ? x : 0;
const safeY = (typeof y === "number" && !isNaN(y)) ? y : 0;
return {
"id": windowId,
"title": title,
"appId": appId,
"workspaceId": wsId || -1,
"isFocused": focused,
"output": output,
"x": safeX,
"y": safeY
};
} catch (e) {
return null;
}
}
function toSortedWindowList(windowList) {
return windowList.sort((a, b) => {
// Sort by workspace first (just in case they are mixed)
if (a.workspaceId !== b.workspaceId) {
return a.workspaceId - b.workspaceId;
}
// Then sort by X position (left to right)
if (a.x !== b.x) {
return a.x - b.x;
}
// Then sort by Y position (top to bottom)
if (a.y !== b.y) {
return a.y - b.y;
}
// Fallback to Window ID mapping
return a.id.localeCompare(b.id);
});
}
function getAppTitle(toplevel) {
try {
var title = toplevel.wayland.title;
if (title)
return title;
} catch (e) {}
return safeGetProperty(toplevel, "title", "");
}
function getAppId(toplevel) {
if (!toplevel)
return "";
var appId = "";
// Try the wayland object first!
// From my (Lemmy) testing it works fine so we could probably get rid of all the other attempts below.
// Leaving them in for now, just in case...
try {
appId = toplevel.wayland.appId;
if (appId)
return appId;
} catch (e) {}
// Try direct properties
appId = safeGetProperty(toplevel, "class", "");
if (appId)
return appId;
appId = safeGetProperty(toplevel, "initialClass", "");
if (appId)
return appId;
appId = safeGetProperty(toplevel, "appId", "");
if (appId)
return appId;
// Try lastIpcObject
try {
const ipcData = toplevel.lastIpcObject;
if (ipcData) {
return String(ipcData.class || ipcData.initialClass || ipcData.appId || ipcData.wm_class || "");
}
} catch (e) {}
return "";
}
// Safe property getter
function safeGetProperty(obj, prop, defaultValue) {
try {
const value = obj[prop];
if (value !== undefined && value !== null) {
return String(value);
}
} catch (e)
// Property access failed
{}
return defaultValue;
}
function handleActiveLayoutEvent(ev) {
try {
let beforeParenthesis;
const parenthesisPos = ev.lastIndexOf('(');
if (parenthesisPos === -1) {
beforeParenthesis = ev;
} else {
beforeParenthesis = ev.substring(0, parenthesisPos);
}
const layoutNameStart = beforeParenthesis.lastIndexOf(',') + 1;
const layoutName = ev.substring(layoutNameStart);
// Ignore bogus "error" layout reported by virtual keyboards (e.g. wtype)
if (layoutName.toLowerCase() === "error") {
Logger.d("HyprlandService", "Ignoring bogus 'error' layout from activelayout event");
return;
}
KeyboardLayoutService.setCurrentLayout(layoutName);
Logger.d("HyprlandService", "Keyboard layout switched:", layoutName);
} catch (e) {
Logger.e("HyprlandService", "Error handling activelayout:", e);
}
}
// Connections to Hyprland
Connections {
target: Hyprland.workspaces
enabled: initialized
function onValuesChanged() {
Qt.callLater(_deferredWorkspaceUpdate);
}
}
Connections {
target: Hyprland.toplevels
enabled: initialized
function onValuesChanged() {
updateTimer.restart();
}
}
Connections {
target: Hyprland
enabled: initialized
function onRawEvent(event) {
Hyprland.refreshWorkspaces();
Hyprland.refreshToplevels();
// Workspace and window updates are deferred — refreshWorkspaces()/
// refreshToplevels() trigger onValuesChanged which also calls
// Qt.callLater, so the deduplication coalesces into one update.
Qt.callLater(_deferredWorkspaceUpdate);
updateTimer.restart();
const monitorsEvents = ["configreloaded", "monitoradded", "monitorremoved", "monitoraddedv2", "monitorremovedv2"];
if (monitorsEvents.includes(event.name)) {
Qt.callLater(queryDisplayScales);
}
if (event.name == "activelayout") {
handleActiveLayoutEvent(event.data);
}
}
}
// Public functions
function switchToWorkspace(workspace) {
try {
if (workspace.name) {
Hyprland.dispatch(`workspace ${workspace.name}`);
return;
}
Hyprland.dispatch(`workspace ${workspace.idx}`);
} catch (e) {
Logger.e("HyprlandService", "Failed to switch workspace:", e);
}
}
function focusWindow(window) {
try {
if (!window || !window.id) {
Logger.w("HyprlandService", "Invalid window object for focus");
return;
}
const windowId = window.id.toString();
Hyprland.dispatch(`focuswindow address:0x${windowId}`);
Hyprland.dispatch(`alterzorder top,address:0x${windowId}`); // Bring the focused window to the top (essential for Float Mode)
} catch (e) {
Logger.e("HyprlandService", "Failed to switch window:", e);
}
}
function closeWindow(window) {
try {
Hyprland.dispatch(`killwindow address:0x${window.id}`);
} catch (e) {
Logger.e("HyprlandService", "Failed to close window:", e);
}
}
function turnOffMonitors() {
try {
Quickshell.execDetached(["hyprctl", "dispatch", "dpms", "off"]);
} catch (e) {
Logger.e("HyprlandService", "Failed to turn off monitors:", e);
}
}
function turnOnMonitors() {
try {
Quickshell.execDetached(["hyprctl", "dispatch", "dpms", "on"]);
} catch (e) {
Logger.e("HyprlandService", "Failed to turn on monitors:", e);
}
}
function logout() {
try {
Quickshell.execDetached(["hyprctl", "dispatch", "exit"]);
} catch (e) {
Logger.e("HyprlandService", "Failed to logout:", e);
}
}
function cycleKeyboardLayout() {
try {
Quickshell.execDetached(["hyprctl", "switchxkblayout", "all", "next"]);
} catch (e) {
Logger.e("HyprlandService", "Failed to cycle keyboard layout:", e);
}
}
function getFocusedScreen() {
const hyprMon = Hyprland.focusedMonitor;
if (hyprMon) {
const monitorName = hyprMon.name;
for (let i = 0; i < Quickshell.screens.length; i++) {
if (Quickshell.screens[i].name === monitorName) {
return Quickshell.screens[i];
}
}
}
return null;
}
function spawn(command) {
try {
Quickshell.execDetached(["hyprctl", "dispatch", "--", "exec"].concat(command));
} catch (e) {
Logger.e("HyprlandService", "Failed to spawn command:", e);
}
}
}
@@ -0,0 +1,300 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.WindowManager
import qs.Commons
Item {
id: root
property ListModel workspaces: ListModel {}
property var windows: []
property int focusedWindowIndex: -1
property var trackedToplevels: new Set()
// LabWC typically has global workspaces (shared across all outputs)
property bool globalWorkspaces: true
// Map from native workspace id to the native Workspace object for activation
property var nativeWorkspaceMap: ({})
// Set of workspace objects we've already connected signals to
property var connectedWorkspaces: ({})
signal workspaceChanged
signal activeWindowChanged
signal windowListChanged
signal displayScalesChanged
function initialize() {
updateWindows();
connectWorkspaceSignals();
syncWorkspaces();
Logger.i("LabwcService", "Service started (ext-workspace-v1)");
}
// Watch for windowsets being added/removed
Connections {
target: WindowManager
function onWindowsetsChanged() {
root.connectWorkspaceSignals();
Qt.callLater(root.syncWorkspaces);
}
function onWindowsetProjectionsChanged() {
Qt.callLater(root.syncWorkspaces);
}
}
// Re-check windowsets after a short delay - the Wayland protocol data
// may arrive after init and the changed signal can be missed
Timer {
interval: 500
running: true
repeat: false
onTriggered: {
if (WindowManager.windowsets.length > 0) {
root.connectWorkspaceSignals();
root.syncWorkspaces();
}
}
}
// Connect to property change signals on each native windowset object
function connectWorkspaceSignals() {
const nativeWs = WindowManager.windowsets;
const newConnected = {};
for (const ws of nativeWs) {
const key = ws.id || ws.toString();
newConnected[key] = true;
if (connectedWorkspaces[key])
continue;
ws.activeChanged.connect(() => {
Qt.callLater(root.syncWorkspaces);
});
ws.urgentChanged.connect(() => {
Qt.callLater(root.syncWorkspaces);
});
ws.shouldDisplayChanged.connect(() => {
Qt.callLater(root.syncWorkspaces);
});
ws.nameChanged.connect(() => {
Qt.callLater(root.syncWorkspaces);
});
}
connectedWorkspaces = newConnected;
}
function syncWorkspaces() {
const nativeWs = WindowManager.windowsets;
workspaces.clear();
nativeWorkspaceMap = {};
let idx = 1;
for (const ws of nativeWs) {
// Skip hidden workspaces (shouldDisplay = false means hidden)
if (!ws.shouldDisplay) {
continue;
}
// Find which outputs this windowset's projection spans
let outputName = "";
if (ws.projection) {
const projScreens = ws.projection.screens;
if (projScreens && projScreens.length > 0) {
outputName = projScreens[0].name || "";
}
}
const wsEntry = {
"id": ws.id || idx.toString(),
"idx": idx,
"name": ws.name || ("Workspace " + idx),
"output": outputName,
"isFocused": ws.active,
"isActive": true,
"isUrgent": ws.urgent,
"isOccupied": false,
"oid": ws.id || idx.toString()
};
workspaces.append(wsEntry);
nativeWorkspaceMap[wsEntry.id] = ws;
idx++;
}
// Update windows with workspace info
updateWindowWorkspaces();
workspaceChanged();
}
function updateWindowWorkspaces() {
// ext-workspace-v1 doesn't provide window-to-workspace mapping
// Assign all windows to the active workspace
let activeId = "";
for (let i = 0; i < workspaces.count; i++) {
const ws = workspaces.get(i);
if (ws.isFocused) {
activeId = ws.id;
break;
}
}
for (let i = 0; i < windows.length; i++) {
if (activeId) {
windows[i].workspaceId = activeId;
}
}
windowListChanged();
}
Connections {
target: ToplevelManager.toplevels
function onValuesChanged() {
updateWindows();
}
}
function connectToToplevel(toplevel) {
if (!toplevel)
return;
toplevel.activatedChanged.connect(() => {
Qt.callLater(onToplevelActivationChanged);
});
toplevel.titleChanged.connect(() => {
Qt.callLater(updateWindows);
});
}
function onToplevelActivationChanged() {
updateWindows();
activeWindowChanged();
}
function updateWindows() {
const newWindows = [];
const toplevels = ToplevelManager.toplevels?.values || [];
let focusedIdx = -1;
let idx = 0;
// Find active workspace id
let activeId = "";
for (let i = 0; i < workspaces.count; i++) {
const ws = workspaces.get(i);
if (ws.isFocused) {
activeId = ws.id;
break;
}
}
for (const toplevel of toplevels) {
if (!toplevel)
continue;
if (!trackedToplevels.has(toplevel)) {
connectToToplevel(toplevel);
trackedToplevels.add(toplevel);
}
// Get output name from toplevel's screen list
const output = (toplevel.screens && toplevel.screens.length > 0) ? (toplevel.screens[0].name || "") : "";
// Use appId + title as a stable id since Toplevel has no address property
const windowId = (toplevel.appId || "") + ":" + idx;
newWindows.push({
"id": windowId,
"appId": toplevel.appId || "",
"title": toplevel.title || "",
"output": output,
"workspaceId": activeId || "1",
"isFocused": toplevel.activated || false,
"toplevel": toplevel
});
if (toplevel.activated) {
focusedIdx = idx;
}
idx++;
}
windows = newWindows;
focusedWindowIndex = focusedIdx;
windowListChanged();
}
function focusWindow(window) {
if (window.toplevel && typeof window.toplevel.activate === "function") {
window.toplevel.activate();
}
}
function closeWindow(window) {
if (window.toplevel && typeof window.toplevel.close === "function") {
window.toplevel.close();
}
}
function switchToWorkspace(workspace) {
// Find the native Workspace object and activate it directly
const nativeWs = nativeWorkspaceMap[workspace.id] || nativeWorkspaceMap[workspace.oid];
if (nativeWs && nativeWs.canActivate) {
nativeWs.activate();
} else {
Logger.w("LabwcService", "Cannot activate workspace: " + (workspace.name || workspace.id));
}
}
function turnOffMonitors() {
try {
Quickshell.execDetached(["wlr-randr", "--off"]);
} catch (e) {
Logger.e("LabwcService", "Failed to turn off monitors:", e);
}
}
function turnOnMonitors() {
try {
Quickshell.execDetached(["wlr-randr", "--on"]);
} catch (e) {
Logger.e("LabwcService", "Failed to turn on monitors:", e);
}
}
function logout() {
try {
// Exit labwc by sending SIGTERM to $LABWC_PID or using --exit flag
Quickshell.execDetached(["sh", "-c", "labwc --exit || kill -s SIGTERM $LABWC_PID"]);
} catch (e) {
Logger.e("LabwcService", "Failed to logout:", e);
}
}
function cycleKeyboardLayout() {
Logger.w("LabwcService", "Keyboard layout cycling not supported");
}
function queryDisplayScales() {
Logger.w("LabwcService", "Display scale queries not supported via ToplevelManager");
}
function getFocusedScreen() {
// de-activated until proper testing
return null;
}
}
@@ -0,0 +1,491 @@
import QtQuick
import Quickshell
import Quickshell.DWL
import Quickshell.Io
import Quickshell.Wayland
import qs.Commons
import qs.Services.Keyboard
Item {
id: root
// ===== PUBLIC INTERFACE (CompositorService compatibility) =====
property ListModel workspaces: ListModel {}
property var windows: []
property int focusedWindowIndex: -1
property bool initialized: false
signal workspaceChanged
signal activeWindowChanged
signal windowListChanged
signal displayScalesChanged
// ===== MANGOSERVICE-SPECIFIC PROPERTIES =====
property string selectedMonitor: ""
property string currentLayoutSymbol: ""
// ===== INTERNAL STATE =====
QtObject {
id: internal
// Window-to-tag persistence: Map<UniqueID, TagID>
property var windowTagMap: ({})
// Window-to-output persistence: Map<UniqueID, OutputName>
property var windowOutputMap: ({})
// Toplevel-to-ID mapping: Map<ToplevelObject, UniqueID>
property var toplevelIdMap: new Map()
property int windowIdCounter: 0
// Output name to index mapping for unique workspace IDs
property var outputIndices: ({})
property int outputCounter: 0
// Monitor scales: Map<OutputName, scale>
property var monitorScales: ({})
// Window signature for change detection
property string lastWindowSignature: ""
// Scale regex
readonly property var scalePattern: /^(\S+)\s+scale_factor\s+(\d+(?:\.\d+)?)$/
// Get all screen names mapped to their DwlIpcOutput
function getOutputMap() {
const map = {};
const screens = Quickshell.screens;
for (let i = 0; i < screens.length; i++) {
const name = screens[i].name;
const dwlOutput = DwlIpc.outputForName(name);
if (dwlOutput) {
map[name] = dwlOutput;
}
}
return map;
}
// ===== REBUILD WORKSPACES FROM DWL =====
function rebuildWorkspaces() {
if (!DwlIpc.available) {
return;
}
const outputMap = getOutputMap();
const workspaceList = [];
for (const outputName in outputMap) {
const output = outputMap[outputName];
// Assign stable index to output
if (internal.outputIndices[outputName] === undefined) {
internal.outputIndices[outputName] = internal.outputCounter++;
}
const outputIdx = internal.outputIndices[outputName];
// Track selected monitor and layout
if (output.active) {
root.selectedMonitor = outputName;
root.currentLayoutSymbol = output.layoutSymbol;
}
const tags = output.tags;
for (let ti = 0; ti < tags.length; ti++) {
const tag = tags[ti];
const tagId = tag.index + 1; // DwlTag.index is zero-based, our IDs are 1-based
const uniqueId = outputIdx * 100 + tagId;
workspaceList.push({
id: uniqueId,
idx: tagId,
name: tagId.toString(),
output: outputName,
isActive: tag.active,
isFocused: tag.active && output.active,
isUrgent: tag.urgent,
isOccupied: tag.clientCount > 0
});
}
}
// Sort by unique ID
workspaceList.sort((a, b) => a.id - b.id);
root.workspaces.clear();
for (let k = 0; k < workspaceList.length; k++) {
root.workspaces.append(workspaceList[k]);
}
root.workspaceChanged();
}
// ===== UPDATE WINDOWS =====
function updateWindows() {
if (!ToplevelManager.toplevels || !DwlIpc.available) {
return;
}
const outputMap = getOutputMap();
const toplevels = ToplevelManager.toplevels.values;
const windowList = [];
let newFocusedIdx = -1;
const currentWindows = new Set();
// Build per-output state from DWL
// Map<outputName, { title, appId, activeTagId }>
// Always populated (activeTagId needed for visible-window inference),
// title/appId may be empty if no window is focused.
const outputState = {};
for (const outputName in outputMap) {
const output = outputMap[outputName];
// Find active tag for this output
let activeTagId = 1;
const tags = output.tags;
for (let ti = 0; ti < tags.length; ti++) {
if (tags[ti].active) {
activeTagId = tags[ti].index + 1;
break;
}
}
outputState[outputName] = {
title: output.title || "",
appId: output.appId || "",
activeTagId: activeTagId
};
// Ensure output index exists
if (internal.outputIndices[outputName] === undefined) {
internal.outputIndices[outputName] = internal.outputCounter++;
}
}
for (let i = 0; i < toplevels.length; i++) {
const toplevel = toplevels[i];
if (!toplevel || toplevel.outliers) {
continue;
}
const appId = toplevel.appId || toplevel.wayland?.appId || "";
const title = toplevel.title || toplevel.wayland?.title || "";
const isFocused = toplevel.activated;
// Get or assign a stable ID
let windowId;
if (internal.toplevelIdMap.has(toplevel)) {
windowId = internal.toplevelIdMap.get(toplevel);
} else {
windowId = `win-${internal.windowIdCounter++}`;
internal.toplevelIdMap.set(toplevel, windowId);
}
currentWindows.add(windowId);
// Determine output
let outputName;
// Priority 1: Focused window matched to DWL output metadata
if (isFocused && (title || appId)) {
for (const oName in outputState) {
const os = outputState[oName];
if ((os.title || os.appId) && title === os.title && appId === os.appId) {
outputName = oName;
internal.windowOutputMap[windowId] = oName;
break;
}
}
}
// Priority 2: Remembered output
if (!outputName && internal.windowOutputMap[windowId]) {
outputName = internal.windowOutputMap[windowId];
}
// Priority 3: toplevel.screens (wlr-foreign-toplevel visible screens)
if (!outputName && toplevel.screens && toplevel.screens.length > 0) {
outputName = toplevel.screens[0].name;
}
// Fallback: selected monitor
if (!outputName) {
outputName = root.selectedMonitor || "DP-1";
}
// Determine tag
let tagId = null;
const os = outputState[outputName];
if (isFocused && os && !os.consumed && (os.title || os.appId) && title === os.title && appId === os.appId) {
// Focused window: assign to the active tag from DWL metadata
tagId = os.activeTagId;
internal.windowTagMap[windowId] = tagId;
// Consume so a second toplevel with identical title+appId cannot also claim focus
os.consumed = true;
} else if (internal.windowTagMap[windowId] !== undefined) {
// Previously seen window: use remembered tag
tagId = internal.windowTagMap[windowId];
}
if (tagId === null) {
// DWL only reports the focused window per output, so we can't
// determine the tag for unfocused windows until they gain focus.
continue;
}
// Convert to unique workspace ID
const outputIdx = internal.outputIndices[outputName];
if (outputIdx === undefined) {
Logger.e("MangoService", "No output index for", outputName);
continue;
}
const workspaceId = outputIdx * 100 + tagId;
windowList.push({
id: `${outputName}:${appId}:${title}:${i}`,
title: title,
appId: appId,
class: appId,
workspaceId: workspaceId,
isFocused: isFocused,
output: outputName,
handle: toplevel,
fullscreen: toplevel.fullscreen || false,
floating: toplevel.maximized === false && toplevel.fullscreen === false
});
if (isFocused) {
newFocusedIdx = windowList.length - 1;
}
}
// Clean up stale window tracking
if (Object.keys(internal.windowTagMap).length > toplevels.length + 20) {
const newTagMap = {};
const newOutputMap = {};
for (const windowId of currentWindows) {
if (internal.windowTagMap[windowId] !== undefined) {
newTagMap[windowId] = internal.windowTagMap[windowId];
}
if (internal.windowOutputMap[windowId] !== undefined) {
newOutputMap[windowId] = internal.windowOutputMap[windowId];
}
}
internal.windowTagMap = newTagMap;
internal.windowOutputMap = newOutputMap;
}
// Check if window list changed
const signature = JSON.stringify(windowList.map(w => w.id + w.workspaceId + w.isFocused));
if (signature !== internal.lastWindowSignature) {
internal.lastWindowSignature = signature;
root.windows = windowList;
root.windowListChanged();
}
if (newFocusedIdx !== root.focusedWindowIndex) {
root.focusedWindowIndex = newFocusedIdx;
root.activeWindowChanged();
}
}
// ===== PROCESS SCALES =====
function processScales(output) {
const lines = output.trim().split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const match = line.match(scalePattern);
if (match) {
const outputName = match[1];
const scale = parseFloat(match[2]);
internal.monitorScales[outputName] = scale;
}
}
const scalesMap = {};
for (const name in internal.monitorScales) {
scalesMap[name] = {
name: name,
scale: internal.monitorScales[name] || 1.0,
width: 0,
height: 0,
x: 0,
y: 0
};
}
if (CompositorService && CompositorService.onDisplayScalesUpdated) {
CompositorService.onDisplayScalesUpdated(scalesMap);
}
root.displayScalesChanged();
}
}
// ===== DWL CONNECTIONS =====
// React to DWL frame events on each output (atomic state updates)
Instantiator {
model: DwlIpc.outputs
delegate: Connections {
required property DwlIpcOutput modelData
target: modelData
function onFrame() {
internal.rebuildWorkspaces();
internal.updateWindows();
}
function onKbLayoutChanged() {
if (KeyboardLayoutService) {
KeyboardLayoutService.setCurrentLayout(modelData.kbLayout);
}
}
}
}
// ===== PROCESSES =====
// Scale query (mmsg -g -A) - DWL doesn't provide scale info
property QtObject _scaleQuery: Process {
id: scaleQuery
command: ["mmsg", "-g", "-A"]
property string buffer: ""
stdout: SplitParser {
onRead: line => {
scaleQuery.buffer += line + "\n";
}
}
onExited: code => {
if (code === 0) {
internal.processScales(scaleQuery.buffer);
scaleQuery.buffer = "";
}
}
}
// ===== TOPLEVEL MANAGER CONNECTION =====
property QtObject _toplevelConnection: Connections {
target: ToplevelManager.toplevels
function onValuesChanged() {
internal.updateWindows();
}
}
// ===== PUBLIC FUNCTIONS =====
function initialize() {
if (initialized) {
return;
}
Logger.i("MangoService", "Initializing MangoWC/DWL compositor integration (DWL protocol)");
// Query display scales (only thing still needing mmsg)
scaleQuery.running = true;
// Initial build from DWL state
if (DwlIpc.available && DwlIpc.outputs.length > 0) {
internal.rebuildWorkspaces();
internal.updateWindows();
}
initialized = true;
}
function queryDisplayScales() {
scaleQuery.running = true;
}
function switchToWorkspace(workspace) {
const tagId = workspace.idx || workspace.id || 1;
const outputName = workspace.output || root.selectedMonitor || "";
// Use DWL protocol to switch tags
const dwlOutput = DwlIpc.outputForName(outputName);
if (dwlOutput) {
dwlOutput.setTags(1 << (tagId - 1)); // tagId is 1-based, bitmask is 0-based
} else {
// Fallback to mmsg
const cmd = ["mmsg", "-s", "-t", tagId.toString()];
if (outputName && Object.keys(internal.monitorScales).length > 1) {
cmd.push("-o", outputName);
}
Quickshell.execDetached(cmd);
}
}
function focusWindow(window) {
if (window && window.handle) {
window.handle.activate();
} else if (window.workspaceId) {
switchToWorkspace({
id: window.workspaceId,
output: window.output
});
}
}
function closeWindow(window) {
if (window && window.handle) {
window.handle.close();
} else {
Quickshell.execDetached(["mmsg", "-s", "-d", "killclient"]);
}
}
function turnOffMonitors() {
const screens = Quickshell.screens;
const cmds = [];
for (let i = 0; i < screens.length; i++) {
cmds.push("mmsg -s -d disable_monitor," + screens[i].name);
}
if (cmds.length > 0) {
Quickshell.execDetached(["sh", "-c", cmds.join(" && ")]);
}
}
function turnOnMonitors() {
const screens = Quickshell.screens;
const cmds = [];
for (let i = 0; i < screens.length; i++) {
cmds.push("mmsg -s -d enable_monitor," + screens[i].name);
}
if (cmds.length > 0) {
Quickshell.execDetached(["sh", "-c", cmds.join(" && ")]);
}
}
function logout() {
Quickshell.execDetached(["mmsg", "-s", "-q"]);
}
function cycleKeyboardLayout() {
Logger.w("MangoService", "Keyboard layout cycling not supported");
}
function getFocusedScreen() {
return null;
}
function spawn(command) {
try {
Quickshell.execDetached(["mmsg", "-s", "-d", "spawn_shell," + command.join(" ")]);
} catch (e) {
Logger.e("MangoService", "Failed to spawn command:", e);
}
}
}
@@ -0,0 +1,294 @@
import QtQuick
import Quickshell
import Quickshell.Niri
import qs.Commons
import qs.Services.Keyboard
Item {
id: root
property int floatingWindowPosition: Number.MAX_SAFE_INTEGER
property ListModel workspaces: ListModel {}
property var windows: []
property int focusedWindowIndex: -1
property bool overviewActive: false
property var keyboardLayouts: []
signal workspaceChanged
signal activeWindowChanged
signal windowListChanged
signal displayScalesChanged
property var outputCache: ({})
property var workspaceCache: ({})
function initialize() {
Niri.refreshOutputs();
Niri.refreshWorkspaces();
Niri.refreshWindows();
Qt.callLater(() => {
safeUpdateOutputs();
safeUpdateWorkspaces();
safeUpdateWindows();
queryDisplayScales();
});
Logger.i("NiriService", "Service started");
}
// Connections to the C++ Niri IPC module
Connections {
target: Niri
function onWorkspacesUpdated() {
safeUpdateWorkspaces();
workspaceChanged();
}
function onWindowsUpdated() {
safeUpdateWindows();
windowListChanged();
activeWindowChanged();
}
function onOutputsUpdated() {
safeUpdateOutputs();
queryDisplayScales();
}
function onOverviewActiveChanged() {
overviewActive = Niri.overviewActive;
}
function onKeyboardLayoutsChanged() {
keyboardLayouts = Niri.keyboardLayoutNames;
const layoutName = Niri.currentKeyboardLayoutName;
if (layoutName) {
KeyboardLayoutService.setCurrentLayout(layoutName);
}
Logger.d("NiriService", "Keyboard layouts changed:", keyboardLayouts.toString());
}
function onKeyboardLayoutSwitched() {
const layoutName = Niri.currentKeyboardLayoutName;
if (layoutName) {
KeyboardLayoutService.setCurrentLayout(layoutName);
}
Logger.d("NiriService", "Keyboard layout switched:", layoutName);
}
}
function safeUpdateOutputs() {
const niriOutputs = Niri.outputs.values;
outputCache = {};
for (var i = 0; i < niriOutputs.length; i++) {
const output = niriOutputs[i];
outputCache[output.name] = {
"name": output.name,
"connected": output.connected,
"scale": output.scale,
"width": output.width,
"height": output.height,
"x": output.x,
"y": output.y,
"physical_width": output.physicalWidth,
"physical_height": output.physicalHeight,
"refresh_rate": output.refreshRate,
"vrr_supported": output.vrrSupported,
"vrr_enabled": output.vrrEnabled,
"transform": output.transform
};
}
}
function safeUpdateWorkspaces() {
const niriWorkspaces = Niri.workspaces.values;
workspaceCache = {};
const workspacesList = [];
for (var i = 0; i < niriWorkspaces.length; i++) {
const ws = niriWorkspaces[i];
const wsData = {
"id": ws.id,
"idx": ws.idx,
"name": ws.name,
"output": ws.output,
"isFocused": ws.focused,
"isActive": ws.active,
"isUrgent": ws.urgent,
"isOccupied": ws.occupied
};
workspacesList.push(wsData);
workspaceCache[ws.id] = wsData;
}
// Workspaces come pre-sorted from C++ (by output then idx)
workspaces.clear();
for (var j = 0; j < workspacesList.length; j++) {
workspaces.append(workspacesList[j]);
}
}
function getWindowOutput(win) {
for (var i = 0; i < workspaces.count; i++) {
if (workspaces.get(i).id === win.workspaceId) {
return workspaces.get(i).output;
}
}
return null;
}
function toSortedWindowList(windowList) {
return windowList.map(win => {
const workspace = workspaceCache[win.workspaceId];
const output = (workspace && workspace.output) ? outputCache[workspace.output] : null;
return {
window: win,
workspaceIdx: workspace ? workspace.idx : 0,
outputX: output ? output.x : 0,
outputY: output ? output.y : 0
};
}).sort((a, b) => {
// Sort by output position first
if (a.outputX !== b.outputX) {
return a.outputX - b.outputX;
}
if (a.outputY !== b.outputY) {
return a.outputY - b.outputY;
}
// Then by workspace index
if (a.workspaceIdx !== b.workspaceIdx) {
return a.workspaceIdx - b.workspaceIdx;
}
// Then by window position
if (a.window.position.x !== b.window.position.x) {
return a.window.position.x - b.window.position.x;
}
if (a.window.position.y !== b.window.position.y) {
return a.window.position.y - b.window.position.y;
}
// Finally by window ID to ensure consistent ordering
return a.window.id - b.window.id;
}).map(info => info.window);
}
function safeUpdateWindows() {
const niriWindows = Niri.windows.values;
const windowsList = [];
for (var i = 0; i < niriWindows.length; i++) {
const win = niriWindows[i];
windowsList.push({
"id": win.id,
"title": win.title || "",
"appId": win.appId || "",
"workspaceId": win.workspaceId || -1,
"isFocused": win.focused,
"output": win.output || getWindowOutput(win) || "",
"position": {
"x": win.isFloating ? floatingWindowPosition : win.positionX,
"y": win.isFloating ? floatingWindowPosition : win.positionY
}
});
}
windows = toSortedWindowList(windowsList);
safeUpdateFocusedWindow();
}
function safeUpdateFocusedWindow() {
focusedWindowIndex = -1;
for (var i = 0; i < windows.length; i++) {
if (windows[i].isFocused) {
focusedWindowIndex = i;
break;
}
}
}
function queryDisplayScales() {
if (CompositorService && CompositorService.onDisplayScalesUpdated) {
CompositorService.onDisplayScalesUpdated(outputCache);
}
}
function switchToWorkspace(workspace) {
try {
Niri.dispatch(["focus-workspace", workspace.idx.toString()]);
} catch (e) {
Logger.e("NiriService", "Failed to switch workspace:", e);
}
}
function scrollWorkspaceContent(direction) {
try {
var action = direction < 0 ? "focus-column-left" : "focus-column-right";
Niri.dispatch([action]);
} catch (e) {
Logger.e("NiriService", "Failed to scroll workspace content:", e);
}
}
function focusWindow(window) {
try {
Niri.dispatch(["focus-window", "--id", window.id.toString()]);
} catch (e) {
Logger.e("NiriService", "Failed to switch window:", e);
}
}
function closeWindow(window) {
try {
Niri.dispatch(["close-window", "--id", window.id.toString()]);
} catch (e) {
Logger.e("NiriService", "Failed to close window:", e);
}
}
function turnOffMonitors() {
try {
Niri.dispatch(["power-off-monitors"]);
} catch (e) {
Logger.e("NiriService", "Failed to turn off monitors:", e);
}
}
function turnOnMonitors() {
try {
Niri.dispatch(["power-on-monitors"]);
} catch (e) {
Logger.e("NiriService", "Failed to turn on monitors:", e);
}
}
function logout() {
try {
Niri.dispatch(["quit", "--skip-confirmation"]);
} catch (e) {
Logger.e("NiriService", "Failed to logout:", e);
}
}
function cycleKeyboardLayout() {
try {
Niri.dispatch(["switch-layout", "next"]);
} catch (e) {
Logger.e("NiriService", "Failed to cycle keyboard layout:", e);
}
}
function getFocusedScreen() {
// On niri the code below only works when you have an actual app selected on that screen.
return null;
}
function spawn(command) {
try {
const niriArgs = ["spawn", "--"].concat(command);
Logger.d("NiriService", "Calling niri spawn: niri msg action " + niriArgs.join(" "));
Niri.dispatch(niriArgs);
} catch (e) {
Logger.e("NiriService", "Failed to spawn command:", e);
}
}
}
@@ -0,0 +1,593 @@
import QtQuick
import Quickshell
import Quickshell.I3
import Quickshell.Io
import Quickshell.Wayland
import qs.Commons
import qs.Services.Keyboard
Item {
id: root
// Configurable IPC command name (overridden to "scrollmsg" for Scroll)
property string msgCommand: "swaymsg"
// Properties that match the facade interface
property ListModel workspaces: ListModel {}
property var windows: []
property int focusedWindowIndex: -1
// Signals that match the facade interface
signal workspaceChanged
signal activeWindowChanged
signal windowListChanged
signal displayScalesChanged
// I3-specific properties
property bool initialized: false
// Cache for window-to-workspace mapping
property var windowWorkspaceMap: ({})
// Track window usage counts per workspace to handle duplicates
property var windowUsageCountsPerWorkspace: ({})
// Debounce timer for updates
Timer {
id: updateTimer
interval: 50
repeat: false
onTriggered: safeUpdate()
}
// Initialization
function initialize() {
if (initialized)
return;
try {
I3.refreshWorkspaces();
Qt.callLater(() => {
safeUpdateWorkspaces();
queryWindowWorkspaces();
queryDisplayScales();
queryKeyboardLayout();
});
initialized = true;
Logger.i("SwayService", "Service started");
} catch (e) {
Logger.e("SwayService", "Failed to initialize:", e);
}
}
// Query window-to-workspace mapping via IPC
function queryWindowWorkspaces() {
swayTreeProcess.running = true;
}
// Sway tree process for getting window workspace information
Process {
id: swayTreeProcess
running: false
command: [msgCommand, "-t", "get_tree", "-r"]
property string accumulatedOutput: ""
stdout: SplitParser {
onRead: function (line) {
swayTreeProcess.accumulatedOutput += line;
}
}
onExited: function (exitCode) {
if (exitCode !== 0 || !accumulatedOutput) {
Logger.e("SwayService", "Failed to query tree, exit code:", exitCode);
accumulatedOutput = "";
return;
}
try {
const treeData = JSON.parse(accumulatedOutput);
const newMap = {};
const workspaceWindows = {}; // Track windows per workspace
// Recursively find all windows and their workspaces
function traverseTree(node, workspaceNum) {
if (!node)
return;
// If this is a workspace node, update the workspace number
if (node.type === "workspace" && node.num !== undefined) {
workspaceNum = node.num;
if (!workspaceWindows[workspaceNum]) {
workspaceWindows[workspaceNum] = [];
}
}
// If this is a regular or floating container with app_id/class (i.e., a window)
if ((node.type === "con" || node.type === "floating_con") && (node.app_id || node.window_properties)) {
const appId = node.app_id || (node.window_properties ? node.window_properties.class : null);
const title = node.name || "";
const id = node.id;
if (appId && workspaceNum !== undefined && workspaceNum >= 0) {
// Store window info for this workspace
workspaceWindows[workspaceNum].push({
appId: appId,
title: title,
id: id
});
}
}
// Traverse children
if (node.nodes && node.nodes.length > 0) {
for (const child of node.nodes) {
traverseTree(child, workspaceNum);
}
}
// Traverse floating nodes
if (node.floating_nodes && node.floating_nodes.length > 0) {
for (const child of node.floating_nodes) {
traverseTree(child, workspaceNum);
}
}
}
traverseTree(treeData, -1);
// Now build the map with workspace-specific keys
for (const wsNum in workspaceWindows) {
const windows = workspaceWindows[wsNum];
const appTitleCounts = {}; // Count occurrences of each appId:title in this workspace
for (const win of windows) {
const baseKey = `${win.appId}:${win.title}`;
// Track how many times we've seen this appId:title combo in this workspace
if (!appTitleCounts[baseKey]) {
appTitleCounts[baseKey] = 0;
}
const occurrence = appTitleCounts[baseKey];
appTitleCounts[baseKey]++;
// Create unique key with workspace and occurrence index
const uniqueKey = `ws${wsNum}:${baseKey}[${occurrence}]`;
newMap[uniqueKey] = parseInt(wsNum);
// Also store by ID if available (most reliable)
if (win.id) {
newMap[`id:${win.id}`] = parseInt(wsNum);
}
}
}
windowWorkspaceMap = newMap;
// Update windows with new workspace information
Qt.callLater(safeUpdateWindows);
} catch (e) {
Logger.e("SwayService", "Failed to parse tree:", e);
} finally {
accumulatedOutput = "";
}
}
}
// Query display scales
function queryDisplayScales() {
swayOutputsProcess.running = true;
}
// Sway outputs process for display scale detection
Process {
id: swayOutputsProcess
running: false
command: [msgCommand, "-t", "get_outputs", "-r"]
property string accumulatedOutput: ""
stdout: SplitParser {
onRead: function (line) {
swayOutputsProcess.accumulatedOutput += line;
}
}
onExited: function (exitCode) {
if (exitCode !== 0 || !accumulatedOutput) {
Logger.e("SwayService", "Failed to query outputs, exit code:", exitCode);
accumulatedOutput = "";
return;
}
try {
const outputsData = JSON.parse(accumulatedOutput);
const scales = {};
for (const output of outputsData) {
if (output.name) {
scales[output.name] = {
"name": output.name,
"scale": output.scale || 1.0,
"width": output.current_mode ? output.current_mode.width : 0,
"height": output.current_mode ? output.current_mode.height : 0,
"refresh_rate": output.current_mode ? output.current_mode.refresh : 0,
"x": output.rect ? output.rect.x : 0,
"y": output.rect ? output.rect.y : 0,
"active": output.active || false,
"focused": output.focused || false,
"current_workspace": output.current_workspace || ""
};
}
}
// Notify CompositorService (it will emit displayScalesChanged)
if (CompositorService && CompositorService.onDisplayScalesUpdated) {
CompositorService.onDisplayScalesUpdated(scales);
}
} catch (e) {
Logger.e("SwayService", "Failed to parse outputs:", e);
} finally {
// Clear accumulated output for next query
accumulatedOutput = "";
}
}
}
function queryKeyboardLayout() {
swayInputsProcess.running = true;
}
// Sway inputs process for keyboard layout detection
Process {
id: swayInputsProcess
running: false
command: [msgCommand, "-t", "get_inputs", "-r"]
property string accumulatedOutput: ""
stdout: SplitParser {
onRead: function (line) {
// Accumulate lines instead of parsing each one
swayInputsProcess.accumulatedOutput += line;
}
}
onExited: function (exitCode) {
if (exitCode !== 0 || !accumulatedOutput) {
Logger.e("SwayService", "Failed to query inputs, exit code:", exitCode);
accumulatedOutput = "";
return;
}
try {
const inputsData = JSON.parse(accumulatedOutput);
for (const input of inputsData) {
if (input.type == "keyboard") {
const layoutName = input.xkb_active_layout_name;
KeyboardLayoutService.setCurrentLayout(layoutName);
Logger.d("SwayService", "Keyboard layout switched:", layoutName);
break;
}
}
} catch (e) {
Logger.e("SwayService", "Failed to parse inputs:", e);
} finally {
// Clear accumulated output for next query
accumulatedOutput = "";
}
}
}
// Safe update wrapper
function safeUpdate() {
queryWindowWorkspaces();
safeUpdateWorkspaces();
}
// Safe workspace update
function safeUpdateWorkspaces() {
try {
workspaces.clear();
if (!I3.workspaces || !I3.workspaces.values) {
return;
}
const hlWorkspaces = I3.workspaces.values;
for (var i = 0; i < hlWorkspaces.length; i++) {
const ws = hlWorkspaces[i];
if (!ws || ws.id < 1)
continue;
const wsData = {
"id": i,
"idx": ws.num,
"name": ws.name || "",
"output": (ws.monitor && ws.monitor.name) ? ws.monitor.name : "",
"isActive": ws.active === true,
"isFocused": ws.focused === true,
"isUrgent": ws.urgent === true,
"isOccupied": true,
"handle": ws
};
workspaces.append(wsData);
}
} catch (e) {
Logger.e("SwayService", "Error updating workspaces:", e);
}
}
// Safe window update
function safeUpdateWindows() {
try {
const windowsList = [];
// Reset usage counts per workspace before processing windows
windowUsageCountsPerWorkspace = {};
if (!ToplevelManager.toplevels || !ToplevelManager.toplevels.values) {
windows = [];
focusedWindowIndex = -1;
windowListChanged();
return;
}
const hlToplevels = ToplevelManager.toplevels.values;
let newFocusedIndex = -1;
for (var i = 0; i < hlToplevels.length; i++) {
const toplevel = hlToplevels[i];
if (!toplevel)
continue;
const windowData = extractWindowData(toplevel);
if (windowData) {
windowsList.push(windowData);
if (windowData.isFocused) {
newFocusedIndex = windowsList.length - 1;
}
}
}
windows = windowsList;
if (newFocusedIndex !== focusedWindowIndex) {
focusedWindowIndex = newFocusedIndex;
activeWindowChanged();
}
windowListChanged();
} catch (e) {
Logger.e("SwayService", "Error updating windows:", e);
}
}
// Extract window data safely from a toplevel
function extractWindowData(toplevel) {
if (!toplevel)
return null;
try {
// Safely extract properties
const appId = getAppId(toplevel);
const title = safeGetProperty(toplevel, "title", "");
const focused = toplevel.activated === true;
// Try to find workspace ID from our cached map by trying all workspaces
let workspaceId = -1;
let foundWorkspaceNum = -1;
// Build base key for this window
const baseKey = `${appId}:${title}`;
// Try to find this window in any workspace
for (var i = 0; i < workspaces.count; i++) {
const ws = workspaces.get(i);
if (!ws)
continue;
const wsNum = ws.idx;
// Initialize usage count for this workspace if needed
if (!windowUsageCountsPerWorkspace[wsNum]) {
windowUsageCountsPerWorkspace[wsNum] = {};
}
// Get current usage count for this appId:title in this workspace
if (!windowUsageCountsPerWorkspace[wsNum][baseKey]) {
windowUsageCountsPerWorkspace[wsNum][baseKey] = 0;
}
const occurrence = windowUsageCountsPerWorkspace[wsNum][baseKey];
const uniqueKey = `ws${wsNum}:${baseKey}[${occurrence}]`;
// Check if this key exists in our map
if (windowWorkspaceMap[uniqueKey] !== undefined) {
foundWorkspaceNum = windowWorkspaceMap[uniqueKey];
workspaceId = ws.id;
// Increment the usage count for this workspace
windowUsageCountsPerWorkspace[wsNum][baseKey]++;
break;
}
}
return {
"title": title,
"appId": appId,
"isFocused": focused,
"workspaceId": workspaceId,
"handle": toplevel
};
} catch (e) {
return null;
}
}
function getAppId(toplevel) {
if (!toplevel)
return "";
return toplevel.appId;
}
// Safe property getter
function safeGetProperty(obj, prop, defaultValue) {
try {
const value = obj[prop];
if (value !== undefined && value !== null) {
return String(value);
}
} catch (e)
// Property access failed
{}
return defaultValue;
}
function handleInputEvent(ev) {
try {
const eventData = JSON.parse(ev);
if (eventData.change == "xkb_layout" && eventData.input != null) {
const input = eventData.input;
if (input.type == "keyboard" && input.xkb_active_layout_name != null) {
const layoutName = input.xkb_active_layout_name;
KeyboardLayoutService.setCurrentLayout(layoutName);
Logger.d("SwayService", "Keyboard layout switched:", layoutName);
}
}
} catch (e) {
Logger.e("SwayService", "Error handling input event:", e);
}
}
// Connections to I3
Connections {
target: I3.workspaces
enabled: initialized
function onValuesChanged() {
safeUpdateWorkspaces();
workspaceChanged();
}
}
Connections {
target: ToplevelManager
enabled: initialized
function onActiveToplevelChanged() {
updateTimer.restart();
}
}
// Some programs change title of window dependent on content
Connections {
target: ToplevelManager ? ToplevelManager.activeToplevel : null
enabled: initialized
function onTitleChanged() {
updateTimer.restart();
}
}
Connections {
target: I3
enabled: initialized
function onRawEvent(event) {
safeUpdateWorkspaces();
workspaceChanged();
updateTimer.restart();
if (event.type === "output") {
Qt.callLater(queryDisplayScales);
}
}
}
I3IpcListener {
subscriptions: ["input"]
onIpcEvent: function (event) {
handleInputEvent(event.data);
}
}
// Public functions
function switchToWorkspace(workspace) {
try {
workspace.handle.activate();
} catch (e) {
Logger.e("SwayService", "Failed to switch workspace:", e);
}
}
function focusWindow(window) {
try {
window.handle.activate();
} catch (e) {
Logger.e("SwayService", "Failed to switch window:", e);
}
}
function closeWindow(window) {
try {
window.handle.close();
} catch (e) {
Logger.e("SwayService", "Failed to close window:", e);
}
}
function turnOffMonitors() {
try {
Quickshell.execDetached([msgCommand, "output", "*", "dpms", "off"]);
} catch (e) {
Logger.e("SwayService", "Failed to turn off monitors:", e);
}
}
function turnOnMonitors() {
try {
Quickshell.execDetached([msgCommand, "output", "*", "dpms", "on"]);
} catch (e) {
Logger.e("SwayService", "Failed to turn on monitors:", e);
}
}
function logout() {
try {
Quickshell.execDetached([msgCommand, "exit"]);
} catch (e) {
Logger.e("SwayService", "Failed to logout:", e);
}
}
function cycleKeyboardLayout() {
try {
Quickshell.execDetached([msgCommand, "input", "type:keyboard", "xkb_switch_layout", "next"]);
} catch (e) {
Logger.e("SwayService", "Failed to cycle keyboard layout:", e);
}
}
function getFocusedScreen() {
// de-activated until proper testing
return null;
// const i3Mon = I3.focusedMonitor;
// if (i3Mon) {
// const monitorName = i3Mon.name;
// for (let i = 0; i < Quickshell.screens.length; i++) {
// if (Quickshell.screens[i].name === monitorName) {
// return Quickshell.screens[i];
// }
// }
// }
// return null;
}
function spawn(command) {
try {
Quickshell.execDetached([msgCommand, "exec", "--"].concat(command));
} catch (e) {
Logger.e("SwayService", "Failed to spawn command:", e);
}
}
}
@@ -0,0 +1,163 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services.Compositor
/**
* Detects which screen the cursor is currently on by creating a temporary
* invisible PanelWindow. Use withCurrentScreen() to get the screen asynchronously.
*
* Usage:
* CurrentScreenDetector {
* id: screenDetector
* }
*
* function doSomething() {
* screenDetector.withCurrentScreen(function(screen) {
* // screen is the ShellScreen where cursor is
* })
* }
*/
Item {
id: root
// Pending callback to execute once screen is detected
property var pendingCallback: null
property bool pendingSkipBarCheck: false
// Detected screen
property var detectedScreen: null
// Signal emitted when screen is detected from the PanelWindow
signal screenDetected(var detectedScreen)
onScreenDetected: function (detectedScreen) {
root.detectedScreen = detectedScreen;
screenDetectorDebounce.restart();
}
/**
* Find a fallback screen that has a bar configured.
* Prioritizes the screen at position 0x0 (likely the primary screen).
*/
function findScreenWithBar(): var {
const monitors = Settings.data.bar.monitors || [];
let primaryCandidate = null;
let firstWithBar = null;
for (let i = 0; i < Quickshell.screens.length; i++) {
const s = Quickshell.screens[i];
const hasBar = monitors.length === 0 || monitors.includes(s.name);
if (hasBar) {
// Check if this is at 0x0 (primary position)
if (s.x === 0 && s.y === 0) {
primaryCandidate = s;
}
// Track first screen with bar as fallback
if (!firstWithBar) {
firstWithBar = s;
}
}
}
// Prefer primary (0x0), then first with bar, then just first screen
return primaryCandidate || firstWithBar || Quickshell.screens[0];
}
/**
* Execute callback with the screen where the cursor currently is.
* On single-monitor setups, executes immediately.
* On multi-monitor setups, briefly opens an invisible window to detect the screen.
*/
function withCurrentScreen(callback: var, skipBarCheck: bool) {
if (root.pendingCallback) {
Logger.w("CurrentScreenDetector", "Another detection is pending, ignoring new call");
return;
}
// Single monitor setup can execute immediately
if (Quickshell.screens.length === 1) {
callback(Quickshell.screens[0]);
return;
}
// Try compositor-specific focused monitor detection first
let screen = CompositorService.getFocusedScreen();
if (screen) {
// Apply the bar check if configured (skip for overlay launcher etc.)
if (!skipBarCheck && !Settings.data.general.allowPanelsOnScreenWithoutBar) {
const monitors = Settings.data.bar.monitors || [];
const hasBar = monitors.length === 0 || monitors.includes(screen.name);
if (!hasBar) {
screen = findScreenWithBar();
}
}
Logger.d("CurrentScreenDetector", "Using compositor-detected screen:", screen.name);
callback(screen);
return;
}
// Fallback: Multi-monitor setup needs async detection via invisible PanelWindow
root.detectedScreen = null;
root.pendingCallback = callback;
root.pendingSkipBarCheck = !!skipBarCheck;
screenDetectorLoader.active = true;
}
Timer {
id: screenDetectorDebounce
running: false
interval: 40
onTriggered: {
Logger.d("CurrentScreenDetector", "Screen debounced to:", root.detectedScreen?.name || "null");
// Execute pending callback if any
if (root.pendingCallback) {
if (!root.pendingSkipBarCheck && !Settings.data.general.allowPanelsOnScreenWithoutBar) {
// If we explicitly disabled panels on screen without bar, check if bar is configured
// for this screen, and fallback to primary screen if necessary
var monitors = Settings.data.bar.monitors || [];
const hasBar = monitors.length === 0 || monitors.includes(root.detectedScreen?.name);
if (!hasBar) {
root.detectedScreen = findScreenWithBar();
}
}
Logger.d("CurrentScreenDetector", "Executing callback on screen:", root.detectedScreen.name);
// Store callback locally and clear pendingCallback first to prevent deadlock
// if the callback throws an error
var callback = root.pendingCallback;
root.pendingCallback = null;
root.pendingSkipBarCheck = false;
try {
callback(root.detectedScreen);
} catch (e) {
Logger.e("CurrentScreenDetector", "Callback failed:", e);
}
}
// Clean up
screenDetectorLoader.active = false;
}
}
// Invisible dummy PanelWindow to detect which screen should receive the action
Loader {
id: screenDetectorLoader
active: false
sourceComponent: PanelWindow {
implicitWidth: 0
implicitHeight: 0
color: "transparent"
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "noctalia-screen-detector"
mask: Region {}
onScreenChanged: root.screenDetected(screen)
}
}
}
@@ -0,0 +1,318 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.UI
Singleton {
id: root
// Must be called at startup to force singleton instantiation,
// which registers the IpcHandler with the IPC system.
function init() {
Logger.i("CustomButtonIPCService", "Service started");
}
// Registry to store references to active custom buttons by their user-defined identifier
property var customButtonRegistry: ({})
// Register a custom button instance
function registerButton(button) {
if (!button || !button.ipcIdentifier) {
Logger.w("CustomButtonIPCService", "Cannot register button without ipcIdentifier");
return false;
}
customButtonRegistry[button.ipcIdentifier] = button;
Logger.d("CustomButtonIPCService", `Registered button with identifier: ${button.ipcIdentifier}`);
return true;
}
// Unregister a custom button instance
function unregisterButton(button) {
if (!button || !button.ipcIdentifier) {
return false;
}
if (customButtonRegistry[button.ipcIdentifier] === button) {
delete customButtonRegistry[button.ipcIdentifier];
Logger.d("CustomButtonIPCService", `Unregistered button with identifier: ${button.ipcIdentifier}`);
return true;
}
return false;
}
// Find a button by identifier
function findButton(identifier) {
return customButtonRegistry[identifier] || null;
}
// Find button config from Settings for when the live widget is not loaded
function findButtonConfig(identifier) {
var screens = Quickshell.screens;
for (var i = 0; i < screens.length; i++) {
var widgets = Settings.getBarWidgetsForScreen(screens[i].name);
var config = _searchWidgetsForIdentifier(widgets, identifier);
if (config)
return config;
}
// Also check global widgets as a final fallback
var globalConfig = _searchWidgetsForIdentifier(Settings.data.bar.widgets, identifier);
if (globalConfig)
return globalConfig;
return null;
}
function _searchWidgetsForIdentifier(widgets, identifier) {
var sections = ["left", "center", "right"];
for (var s = 0; s < sections.length; s++) {
var list = widgets[sections[s]];
if (!list)
continue;
for (var j = 0; j < list.length; j++) {
var w = list[j];
if (w.id === "CustomButton" && w.ipcIdentifier === identifier) {
return w;
}
}
}
return null;
}
// Resolve a command property from config with fallback to widgetMetadata defaults
function resolveCommand(config, prop) {
if (config[prop])
return config[prop];
var meta = BarWidgetRegistry.widgetMetadata["CustomButton"];
return meta ? (meta[prop] || "") : "";
}
// Substitute $delta expressions in a command string
function substituteWheelDelta(command, delta) {
var normalizedDelta = delta > 0 ? 1 : -1;
return command.replace(/\$delta([+\-*/]\d+)?/g, function (match, operation) {
if (operation) {
try {
var operator = operation.charAt(0);
var operand = parseInt(operation.substring(1));
var result;
switch (operator) {
case '+':
result = normalizedDelta + operand;
break;
case '-':
result = normalizedDelta - operand;
break;
case '*':
result = normalizedDelta * operand;
break;
case '/':
result = Math.floor(normalizedDelta / operand);
break;
default:
result = normalizedDelta;
}
return result.toString();
} catch (e) {
return normalizedDelta.toString();
}
} else {
return normalizedDelta.toString();
}
});
}
// IpcHandler for custom button commands using short alias 'cb'
IpcHandler {
target: "cb"
// Handle left click: cb left "identifier"
function left(identifier: string) {
const button = findButton(identifier);
if (button) {
if (button.leftClickExec || button.textCommand) {
button.clicked();
Logger.i("CustomButtonIPCService", `Triggered left click on button '${identifier}'`);
} else {
Logger.w("CustomButtonIPCService", `Button '${identifier}' has no left click action configured`);
}
return;
}
// Fallback: read from Settings
const config = findButtonConfig(identifier);
if (!config) {
Logger.w("CustomButtonIPCService", `Button with identifier '${identifier}' not found`);
return;
}
const cmd = resolveCommand(config, "leftClickExec");
if (cmd) {
Quickshell.execDetached(["sh", "-lc", cmd]);
Logger.i("CustomButtonIPCService", `Triggered left click on button '${identifier}' (from settings)`);
} else {
Logger.w("CustomButtonIPCService", `Button '${identifier}' has no left click action configured`);
}
}
// Handle right click: cb right "identifier"
function right(identifier: string) {
const button = findButton(identifier);
if (button) {
if (button.rightClickExec) {
button.rightClicked();
Logger.i("CustomButtonIPCService", `Triggered right click on button '${identifier}'`);
} else {
Logger.w("CustomButtonIPCService", `Button '${identifier}' has no right click action configured`);
}
return;
}
const config = findButtonConfig(identifier);
if (!config) {
Logger.w("CustomButtonIPCService", `Button with identifier '${identifier}' not found`);
return;
}
const cmd = resolveCommand(config, "rightClickExec");
if (cmd) {
Quickshell.execDetached(["sh", "-lc", cmd]);
Logger.i("CustomButtonIPCService", `Triggered right click on button '${identifier}' (from settings)`);
} else {
Logger.w("CustomButtonIPCService", `Button '${identifier}' has no right click action configured`);
}
}
// Handle middle click: cb middle "identifier"
function middle(identifier: string) {
const button = findButton(identifier);
if (button) {
if (button.middleClickExec) {
button.middleClicked();
Logger.i("CustomButtonIPCService", `Triggered middle click on button '${identifier}'`);
} else {
Logger.w("CustomButtonIPCService", `Button '${identifier}' has no middle click action configured`);
}
return;
}
const config = findButtonConfig(identifier);
if (!config) {
Logger.w("CustomButtonIPCService", `Button with identifier '${identifier}' not found`);
return;
}
const cmd = resolveCommand(config, "middleClickExec");
if (cmd) {
Quickshell.execDetached(["sh", "-lc", cmd]);
Logger.i("CustomButtonIPCService", `Triggered middle click on button '${identifier}' (from settings)`);
} else {
Logger.w("CustomButtonIPCService", `Button '${identifier}' has no middle click action configured`);
}
}
// Handle wheel up: cb up "identifier"
function up(identifier: string) {
const button = findButton(identifier);
if (button) {
if (button.wheelMode === "separate" && button.wheelUpExec) {
button.wheeled(1);
Logger.i("CustomButtonIPCService", `Triggered wheel up on button '${identifier}'`);
} else {
Logger.w("CustomButtonIPCService", `Button '${identifier}' has no separate wheel up action configured or is not in separate mode`);
}
return;
}
const config = findButtonConfig(identifier);
if (!config) {
Logger.w("CustomButtonIPCService", `Button with identifier '${identifier}' not found`);
return;
}
const mode = config.wheelMode || BarWidgetRegistry.widgetMetadata["CustomButton"].wheelMode;
const cmd = resolveCommand(config, "wheelUpExec");
if (mode === "separate" && cmd) {
const resolved = substituteWheelDelta(cmd, 1);
Quickshell.execDetached(["sh", "-lc", resolved]);
Logger.i("CustomButtonIPCService", `Triggered wheel up on button '${identifier}' (from settings)`);
} else {
Logger.w("CustomButtonIPCService", `Button '${identifier}' has no separate wheel up action configured or is not in separate mode`);
}
}
// Handle wheel down: cb down "identifier"
function down(identifier: string) {
const button = findButton(identifier);
if (button) {
if (button.wheelMode === "separate" && button.wheelDownExec) {
button.wheeled(-1);
Logger.i("CustomButtonIPCService", `Triggered wheel down on button '${identifier}'`);
} else {
Logger.w("CustomButtonIPCService", `Button '${identifier}' has no separate wheel down action configured or is not in separate mode`);
}
return;
}
const config = findButtonConfig(identifier);
if (!config) {
Logger.w("CustomButtonIPCService", `Button with identifier '${identifier}' not found`);
return;
}
const mode = config.wheelMode || BarWidgetRegistry.widgetMetadata["CustomButton"].wheelMode;
const cmd = resolveCommand(config, "wheelDownExec");
if (mode === "separate" && cmd) {
const resolved = substituteWheelDelta(cmd, -1);
Quickshell.execDetached(["sh", "-lc", resolved]);
Logger.i("CustomButtonIPCService", `Triggered wheel down on button '${identifier}' (from settings)`);
} else {
Logger.w("CustomButtonIPCService", `Button '${identifier}' has no separate wheel down action configured or is not in separate mode`);
}
}
// Handle wheel action: cb wheel "identifier"
function wheel(identifier: string) {
const button = findButton(identifier);
if (button) {
if (button.wheelMode === "unified" && button.wheelExec) {
button.wheeled(1);
Logger.i("CustomButtonIPCService", `Triggered wheel action on button '${identifier}'`);
} else {
Logger.w("CustomButtonIPCService", `Button '${identifier}' has no unified wheel action configured or is not in unified mode`);
}
return;
}
const config = findButtonConfig(identifier);
if (!config) {
Logger.w("CustomButtonIPCService", `Button with identifier '${identifier}' not found`);
return;
}
const mode = config.wheelMode || BarWidgetRegistry.widgetMetadata["CustomButton"].wheelMode;
const cmd = resolveCommand(config, "wheelExec");
if (mode === "unified" && cmd) {
const resolved = substituteWheelDelta(cmd, 1);
Quickshell.execDetached(["sh", "-lc", resolved]);
Logger.i("CustomButtonIPCService", `Triggered wheel action on button '${identifier}' (from settings)`);
} else {
Logger.w("CustomButtonIPCService", `Button '${identifier}' has no unified wheel action configured or is not in unified mode`);
}
}
// Handle refresh: cb refresh "identifier"
function refresh(identifier: string) {
const button = findButton(identifier);
if (!button) {
Logger.w("CustomButtonIPCService", `Button '${identifier}' is not currently loaded refresh requires a live widget instance`);
return;
}
if (button.textCommand && button.textCommand.length > 0 && !button.textStream) {
button.runTextCommand();
Logger.i("CustomButtonIPCService", `Triggered refresh (text command) on button '${identifier}'`);
} else if (button.textStream) {
Logger.w("CustomButtonIPCService", `Button '${identifier}' uses streaming, manual refresh disabled`);
} else {
Logger.w("CustomButtonIPCService", `Button '${identifier}' has no text command to refresh`);
}
}
}
}
@@ -0,0 +1,326 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Power
import qs.Services.Theming
import qs.Services.UI
Singleton {
id: root
// Hook connections for automatic script execution
Connections {
target: Settings.data.colorSchemes
function onDarkModeChanged() {
executeDarkModeHook(Settings.data.colorSchemes.darkMode);
}
}
// Pending wallpaper hook when waiting for color generation
property var pendingWallpaperHook: null
Connections {
target: WallpaperService
function onWallpaperChanged(screenName, path) {
// Check if we need to wait for color generation
if (Settings.data.colorSchemes.useWallpaperColors) {
var effectiveMonitor = Settings.data.colorSchemes.monitorForColors;
if (effectiveMonitor === "" || effectiveMonitor === undefined) {
effectiveMonitor = Screen.name;
}
if (screenName === effectiveMonitor) {
// Store pending hook and wait for colors to be generated
root.pendingWallpaperHook = {
path: path,
screenName: screenName
};
return;
}
}
// No color generation, execute immediately
executeWallpaperHook(path, screenName);
}
}
Connections {
target: TemplateProcessor
function onColorsGenerated() {
// Execute pending wallpaper hook after colors are ready
if (root.pendingWallpaperHook) {
const hook = root.pendingWallpaperHook;
root.pendingWallpaperHook = null;
executeWallpaperHook(hook.path, hook.screenName);
}
executeColorGenerationHook();
}
}
// Track lock screen state for unlock hook
property bool wasLocked: false
Connections {
target: PanelService
function onLockScreenChanged() {
if (PanelService.lockScreen) {
lockScreenActiveConnection.target = PanelService.lockScreen;
}
}
}
Connections {
id: lockScreenActiveConnection
target: PanelService.lockScreen
function onActiveChanged() {
// Detect lock: was unlocked, now locked
if (!wasLocked && PanelService.lockScreen.active) {
executeLockHook();
}
// Detect unlock: was locked, now not locked
if (wasLocked && !PanelService.lockScreen.active) {
executeUnlockHook();
}
wasLocked = PanelService.lockScreen.active;
}
}
// Track performance mode state for hooks
property bool wasPerformanceModeEnabled: false
Connections {
target: PowerProfileService
function onNoctaliaPerformanceModeChanged() {
const isEnabled = PowerProfileService.noctaliaPerformanceMode;
// Detect enabled: was disabled, now enabled
if (!wasPerformanceModeEnabled && isEnabled) {
executePerformanceModeEnabledHook();
}
// Detect disabled: was enabled, now disabled
if (wasPerformanceModeEnabled && !isEnabled) {
executePerformanceModeDisabledHook();
}
wasPerformanceModeEnabled = isEnabled;
}
}
// Execute wallpaper change hook
function executeWallpaperHook(wallpaperPath, screenName) {
if (!Settings.data.hooks?.enabled) {
return;
}
const script = Settings.data.hooks?.wallpaperChange;
if (!script || script === "") {
return;
}
try {
const theme = Settings.data.colorSchemes.darkMode ? "dark" : "light";
let command = script.replace(/\$1/g, wallpaperPath);
command = command.replace(/\$2/g, screenName || "");
command = command.replace(/\$3/g, theme);
Quickshell.execDetached(["sh", "-lc", command]);
Logger.d("HooksService", `Executed wallpaper hook: ${command}`);
} catch (e) {
Logger.e("HooksService", `Failed to execute wallpaper hook: ${e}`);
}
}
// Execute dark mode change hook
function executeDarkModeHook(isDarkMode) {
if (!Settings.data.hooks?.enabled) {
return;
}
const script = Settings.data.hooks?.darkModeChange;
if (!script || script === "") {
return;
}
try {
const command = script.replace(/\$1/g, isDarkMode ? "true" : "false");
Quickshell.execDetached(["sh", "-lc", command]);
Logger.d("HooksService", `Executed dark mode hook: ${command}`);
} catch (e) {
Logger.e("HooksService", `Failed to execute dark mode hook: ${e}`);
}
}
// Execute screen lock hook
function executeLockHook() {
if (!Settings.data.hooks?.enabled) {
return;
}
const script = Settings.data.hooks?.screenLock;
if (!script || script === "") {
return;
}
try {
// Pass "lock" as $1 via shell arguments so the script receives it
Quickshell.execDetached(["sh", "-lc", script, "lock-hook", "lock"]);
Logger.d("HooksService", `Executed screen lock hook: ${script}`);
} catch (e) {
Logger.e("HooksService", `Failed to execute screen lock hook: ${e}`);
}
}
// Execute screen unlock hook
function executeUnlockHook() {
if (!Settings.data.hooks?.enabled) {
return;
}
const script = Settings.data.hooks?.screenUnlock;
if (!script || script === "") {
return;
}
try {
// Pass "unlock" as $1 via shell arguments so the script receives it
Quickshell.execDetached(["sh", "-lc", script, "unlock-hook", "unlock"]);
Logger.d("HooksService", `Executed screen unlock hook: ${script}`);
} catch (e) {
Logger.e("HooksService", `Failed to execute screen unlock hook: ${e}`);
}
}
// Execute performance mode enabled hook
function executePerformanceModeEnabledHook() {
if (!Settings.data.hooks?.enabled) {
return;
}
const script = Settings.data.hooks?.performanceModeEnabled;
if (!script || script === "") {
return;
}
try {
Quickshell.execDetached(["sh", "-lc", script]);
} catch (e) {
Logger.e("HooksService", `Failed to execute performance mode enabled hook: ${e}`);
}
}
// Execute performance mode disabled hook
function executePerformanceModeDisabledHook() {
if (!Settings.data.hooks?.enabled) {
return;
}
const script = Settings.data.hooks?.performanceModeDisabled;
if (!script || script === "") {
return;
}
try {
Quickshell.execDetached(["sh", "-lc", script]);
} catch (e) {
Logger.e("HooksService", `Failed to execute performance mode disabled hook: ${e}`);
}
}
// Execute color generation hook
function executeColorGenerationHook() {
if (!Settings.data.hooks?.enabled) {
return;
}
const script = Settings.data.hooks?.colorGeneration;
if (!script || script === "") {
return;
}
try {
const theme = Settings.data.colorSchemes.darkMode ? "dark" : "light";
const command = script.replace(/\$1/g, theme);
Quickshell.execDetached(["sh", "-lc", command]);
Logger.d("HooksService", `Executed color generation hook: ${command}`);
} catch (e) {
Logger.e("HooksService", `Failed to execute color generation hook: ${e}`);
}
}
// Blocking power hook infrastructure
property var pendingPowerCallback: null
Process {
id: powerHookProcess
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
Logger.w("HooksService", `Power hook failed with exit code ${exitCode}`);
}
if (pendingPowerCallback !== null) {
const callback = pendingPowerCallback;
pendingPowerCallback = null;
callback();
}
}
}
function runPowerHook(script, callback) {
pendingPowerCallback = callback;
powerHookProcess.command = ["sh", "-lc", script];
powerHookProcess.running = true;
}
function executeSessionHook(action, callback) {
if (!Settings.data.hooks?.enabled) {
callback();
return;
}
const script = Settings.data.hooks?.session;
if (!script) {
callback();
return;
}
Logger.i("HooksService", `Executing session hook for ${action}`);
runPowerHook(`${script} ${action}`, callback);
}
// Execute startup hook
function executeStartupHook() {
if (!Settings.data.hooks?.enabled) {
return;
}
const script = Settings.data.hooks?.startup;
if (!script || script === "") {
return;
}
try {
Quickshell.execDetached(["sh", "-lc", script]);
Logger.d("HooksService", `Executed startup hook: ${script}`);
} catch (e) {
Logger.e("HooksService", `Failed to execute startup hook: ${e}`);
}
}
// Initialize the service
function init() {
Logger.i("HooksService", "Service started");
// Initialize lock screen state tracking
Qt.callLater(() => {
if (PanelService.lockScreen) {
wasLocked = PanelService.lockScreen.active;
lockScreenActiveConnection.target = PanelService.lockScreen;
}
// Initialize performance mode state tracking
wasPerformanceModeEnabled = PowerProfileService.noctaliaPerformanceMode;
// Execute startup hook
executeStartupHook();
});
}
}
@@ -0,0 +1,940 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Commons
import qs.Modules.Panels.Settings
import qs.Services.Compositor
import qs.Services.Hardware
import qs.Services.Location
import qs.Services.Media
import qs.Services.Networking
import qs.Services.Noctalia
import qs.Services.Power
import qs.Services.System
import qs.Services.Theming
import qs.Services.UI
Singleton {
id: root
// Screen detector, set via init()
property var screenDetector: null
function init(detector) {
root.screenDetector = detector;
Logger.i("IPCService", "Service started");
}
// Helper for index-based notification lookups in IPC
function _getNotificationByIndex(index: string, funcName: string) {
var idx = index === "" ? 0 : parseInt(index);
if (isNaN(idx)) {
Logger.w("IPC", "Argument to ipc call '" + funcName + "' must be a number");
return null;
}
if (idx < 0 || idx >= NotificationService.popupModel.count) {
Logger.w("IPC", "Notification index out of range: " + idx);
return null;
}
return NotificationService.popupModel.get(idx);
}
IpcHandler {
target: "bar"
function toggle() {
BarService.toggleVisibility();
}
function hideBar() {
BarService.hide();
}
function showBar() {
BarService.show();
}
function peek() {
BarService.peek();
}
function setDisplayMode(mode: string, screen: string) {
if (mode === "always_visible" || mode === "non_exclusive" || mode === "auto_hide") {
if (!screen || screen === "all") {
Settings.data.bar.displayMode = mode;
} else {
Settings.setScreenOverride(screen, "displayMode", mode);
}
}
}
function setPosition(position: string, screen: string) {
var valid = position === "top" || position === "bottom" || position === "left" || position === "right";
if (!valid) {
Logger.w("IPC", "Invalid bar position: " + position + ". Valid: top, bottom, left, right");
return;
}
if (!screen || screen === "all") {
Settings.data.bar.position = position;
} else {
Settings.setScreenOverride(screen, "position", position);
}
}
}
// Settings IPC helpers (outside IpcHandler to avoid QVariant IPC warnings)
readonly property var _settingsTabMap: ({
"about": SettingsPanel.Tab.About,
"audio": SettingsPanel.Tab.Audio,
"bar": SettingsPanel.Tab.Bar,
"colorscheme": SettingsPanel.Tab.ColorScheme,
"lockscreen": SettingsPanel.Tab.LockScreen,
"controlcenter": SettingsPanel.Tab.ControlCenter,
"desktopwidgets": SettingsPanel.Tab.DesktopWidgets,
"osd": SettingsPanel.Tab.OSD,
"display": SettingsPanel.Tab.Display,
"dock": SettingsPanel.Tab.Dock,
"general": SettingsPanel.Tab.General,
"hooks": SettingsPanel.Tab.Hooks,
"launcher": SettingsPanel.Tab.Launcher,
"location": SettingsPanel.Tab.Location,
"connections": SettingsPanel.Tab.Connections,
"notifications": SettingsPanel.Tab.Notifications,
"plugins": SettingsPanel.Tab.Plugins,
"sessionmenu": SettingsPanel.Tab.SessionMenu,
"system": SettingsPanel.Tab.System,
"systemmonitor": SettingsPanel.Tab.System,
"userinterface": SettingsPanel.Tab.UserInterface,
"wallpaper": SettingsPanel.Tab.Wallpaper
})
function _parseSettingsTabArg(tabArg) {
var parts = tabArg.split("/");
var tabId = _resolveSettingsTab(parts[0]);
var subTabId = parts.length > 1 ? parseInt(parts[1]) : -1;
return {
"tab": tabId,
"subTab": isNaN(subTabId) ? -1 : subTabId
};
}
function _resolveSettingsTab(tabName) {
if (!tabName)
return SettingsPanel.Tab.General;
var key = tabName.toLowerCase().replace(/[-_]/g, "");
if (key in _settingsTabMap)
return _settingsTabMap[key];
Logger.w("IPC", "Unknown settings tab: " + tabName);
return SettingsPanel.Tab.General;
}
function _settingsToggle(tabId, subTabId) {
root.screenDetector.withCurrentScreen(screen => {
SettingsPanelService.toggle(tabId, subTabId, screen);
});
}
function _settingsOpen(tabId, subTabId) {
root.screenDetector.withCurrentScreen(screen => {
SettingsPanelService.openToTab(tabId, subTabId, screen);
});
}
IpcHandler {
target: "settings"
function toggle() {
root._settingsToggle(SettingsPanel.Tab.General, -1);
}
function toggleTab(tab: string) {
var parsed = root._parseSettingsTabArg(tab);
root._settingsToggle(parsed.tab, parsed.subTab);
}
function open() {
root._settingsOpen(SettingsPanel.Tab.General, -1);
}
function openTab(tab: string) {
var parsed = root._parseSettingsTabArg(tab);
root._settingsOpen(parsed.tab, parsed.subTab);
}
}
IpcHandler {
target: "calendar"
function toggle() {
root.screenDetector.withCurrentScreen(screen => {
var clockPanel = PanelService.getPanel("clockPanel", screen);
clockPanel?.toggle(null, "Clock");
});
}
}
IpcHandler {
target: "notifications"
function toggleHistory() {
// Will attempt to open the panel next to the bar button if any.
root.screenDetector.withCurrentScreen(screen => {
var notificationHistoryPanel = PanelService.getPanel("notificationHistoryPanel", screen);
notificationHistoryPanel.toggle(null, "NotificationHistory");
});
}
function toggleDND() {
NotificationService.doNotDisturb = !NotificationService.doNotDisturb;
}
function enableDND() {
NotificationService.doNotDisturb = true;
}
function disableDND() {
NotificationService.doNotDisturb = false;
}
function clear() {
NotificationService.clearHistory();
}
function dismissOldest() {
NotificationService.dismissOldestPopup();
}
function removeOldestHistory() {
NotificationService.removeOldestHistory();
}
function dismissAll() {
NotificationService.dismissAllPopups();
}
function getHistory(): string {
return JSON.stringify(NotificationService.getHistorySnapshot(), null, 2);
}
function removeFromHistory(id: string): bool {
return NotificationService.removeFromHistory(id);
}
function invokeDefault(index: string): bool {
var notif = root._getNotificationByIndex(index, "notifications invokeDefault");
if (!notif)
return false;
var actions = JSON.parse(notif.actionsJson || "[]");
if (actions.length === 0)
return false;
var actionId = actions.find(a => a.identifier === "default")?.identifier ?? actions[0].identifier;
return NotificationService.invokeAction(notif.id, actionId);
}
function invokeDefaultAndDismiss(index: string): bool {
var notif = root._getNotificationByIndex(index, "notifications invokeDefaultAndDismiss");
if (!notif)
return false;
var actions = JSON.parse(notif.actionsJson || "[]");
if (actions.length === 0) {
NotificationService.dismissPopup(notif.id);
return false;
}
var actionId = actions.find(a => a.identifier === "default")?.identifier ?? actions[0].identifier;
var result = NotificationService.invokeAction(notif.id, actionId);
NotificationService.dismissPopup(notif.id);
return result;
}
function invokeAction(id: string, actionId: string): bool {
if (!id || !actionId) {
Logger.w("IPC", "Both 'id' and 'actionId' are required for 'notifications invokeAction'");
return false;
}
return NotificationService.invokeAction(id, actionId);
}
function getActions(index: string): string {
var notif = root._getNotificationByIndex(index, "notifications getActions");
if (!notif)
return "[]";
return notif.actionsJson || "[]";
}
}
IpcHandler {
target: "toast"
function send(json: string) {
try {
var data = JSON.parse(json);
var title = data.title || "";
var body = data.body || "";
var icon = data.icon || "";
var type = data.type || "notice";
var duration = data.duration;
switch (type) {
case "warning":
ToastService.showWarning(title, body, duration ?? 4000);
break;
case "error":
ToastService.showError(title, body, duration ?? 6000);
break;
default:
ToastService.showNotice(title, body, icon, duration ?? 3000);
}
} catch (error) {
Logger.e("IPC", "Failed to parse toast JSON: " + error);
}
}
function dismiss() {
ToastService.dismissToast();
}
}
// Idle Inhibitor / Keep Awake
IpcHandler {
target: "idleInhibitor"
function toggle() {
IdleInhibitorService.manualToggle();
}
function enable() {
IdleInhibitorService.addManualInhibitor(null);
}
function disable() {
IdleInhibitorService.removeManualInhibitor();
}
function enableFor(seconds: string) {
var secs = parseInt(seconds);
if (isNaN(secs) || secs <= 0) {
Logger.w("IPC", "Argument to 'idleInhibitor enableFor' must be a positive number");
return;
}
IdleInhibitorService.addManualInhibitor(secs);
}
}
IpcHandler {
target: "launcher"
function toggle() {
root.screenDetector.withCurrentScreen(screen => {
var searchText = PanelService.getLauncherSearchText(screen);
var isInAppMode = !searchText.startsWith(">");
if (!PanelService.isLauncherOpen(screen)) {
// Closed -> open in app mode
PanelService.openLauncherWithSearch(screen, "");
} else if (isInAppMode) {
// Already in app mode -> close
PanelService.closeLauncher(screen);
} else {
// In another mode -> switch to app mode
PanelService.setLauncherSearchText(screen, "");
}
}, Settings.data.appLauncher.overviewLayer);
}
function clipboard() {
root.screenDetector.withCurrentScreen(screen => {
var searchText = PanelService.getLauncherSearchText(screen);
var isInClipMode = searchText.startsWith(">clip");
if (!PanelService.isLauncherOpen(screen)) {
// Closed -> open in clipboard mode
PanelService.openLauncherWithSearch(screen, ">clip ");
} else if (isInClipMode) {
// Already in clipboard mode -> close
PanelService.closeLauncher(screen);
} else {
// In another mode -> switch to clipboard mode
PanelService.setLauncherSearchText(screen, ">clip ");
}
}, Settings.data.appLauncher.overviewLayer);
}
function command() {
root.screenDetector.withCurrentScreen(screen => {
var searchText = PanelService.getLauncherSearchText(screen);
var isInCmdMode = searchText.startsWith(">cmd");
if (!PanelService.isLauncherOpen(screen)) {
PanelService.openLauncherWithSearch(screen, ">cmd ");
} else if (isInCmdMode) {
PanelService.closeLauncher(screen);
} else {
PanelService.setLauncherSearchText(screen, ">cmd ");
}
}, Settings.data.appLauncher.overviewLayer);
}
function emoji() {
root.screenDetector.withCurrentScreen(screen => {
var searchText = PanelService.getLauncherSearchText(screen);
var isInEmojiMode = searchText.startsWith(">emoji");
if (!PanelService.isLauncherOpen(screen)) {
// Closed -> open in emoji mode
PanelService.openLauncherWithSearch(screen, ">emoji ");
} else if (isInEmojiMode) {
// Already in emoji mode -> close
PanelService.closeLauncher(screen);
} else {
// In another mode -> switch to emoji mode
PanelService.setLauncherSearchText(screen, ">emoji ");
}
}, Settings.data.appLauncher.overviewLayer);
}
function windows() {
root.screenDetector.withCurrentScreen(screen => {
var searchText = PanelService.getLauncherSearchText(screen);
var isInWindowsMode = searchText.startsWith(">win");
if (!PanelService.isLauncherOpen(screen)) {
PanelService.openLauncherWithSearch(screen, ">win ");
} else if (isInWindowsMode) {
PanelService.closeLauncher(screen);
} else {
PanelService.setLauncherSearchText(screen, ">win ");
}
}, Settings.data.appLauncher.overviewLayer);
}
function settings() {
root.screenDetector.withCurrentScreen(screen => {
var searchText = PanelService.getLauncherSearchText(screen);
var isInSettingsMode = searchText.startsWith(">settings");
if (!PanelService.isLauncherOpen(screen)) {
PanelService.openLauncherWithSearch(screen, ">settings ");
} else if (isInSettingsMode) {
PanelService.closeLauncher(screen);
} else {
PanelService.setLauncherSearchText(screen, ">settings ");
}
}, Settings.data.appLauncher.overviewLayer);
}
}
IpcHandler {
target: "lockScreen"
// New preferred method - lock the screen
function lock() {
// Only lock if not already locked (prevents the red screen issue)
if (!PanelService.lockScreen.active) {
CompositorService.lock();
}
}
}
IpcHandler {
target: "brightness"
function increase() {
BrightnessService.increaseBrightness();
}
function decrease() {
BrightnessService.decreaseBrightness();
}
function set(value: string) {
var val = parseFloat(value);
if (isNaN(val))
return;
// Normalize logic: heuristic handling of 0-100 vs 0-1
if (val > 1.0)
val = val / 100.0;
// Clamp
val = Math.max(0.0, Math.min(1.0, val));
BrightnessService.setBrightness(val);
}
}
IpcHandler {
target: "monitors"
function on() {
CompositorService.turnOnMonitors();
}
function off() {
CompositorService.turnOffMonitors();
}
}
IpcHandler {
target: "darkMode"
function toggle() {
Settings.data.colorSchemes.darkMode = !Settings.data.colorSchemes.darkMode;
}
function setDark() {
Settings.data.colorSchemes.darkMode = true;
}
function setLight() {
Settings.data.colorSchemes.darkMode = false;
}
}
IpcHandler {
target: "nightLight"
function toggle() {
if (!ProgramCheckerService.wlsunsetAvailable) {
Logger.w("IPC", "wlsunset not available, cannot toggle night light");
return;
}
if (Settings.data.nightLight.forced) {
Settings.data.nightLight.forced = false;
} else {
if (Settings.data.nightLight.enabled) {
Settings.data.nightLight.enabled = false;
} else {
Settings.data.nightLight.forced = true;
Settings.data.nightLight.enabled = true;
}
}
}
}
IpcHandler {
target: "colorScheme"
function set(schemeName: string) {
ColorSchemeService.setPredefinedScheme(schemeName);
}
function setGenerationMethod(method: string) {
var valid = false;
for (var i = 0; i < TemplateProcessor.schemeTypes.length; i++) {
if (TemplateProcessor.schemeTypes[i].key === method) {
valid = true;
break;
}
}
if (valid) {
Settings.data.colorSchemes.generationMethod = method;
} else {
Logger.w("IPC", "Invalid generation method received: " + method);
}
}
}
IpcHandler {
target: "volume"
function increase() {
AudioService.increaseVolume();
}
function decrease() {
AudioService.decreaseVolume();
}
function muteOutput() {
AudioService.setOutputMuted(!AudioService.muted);
}
function increaseInput() {
AudioService.increaseInputVolume();
}
function decreaseInput() {
AudioService.decreaseInputVolume();
}
function muteInput() {
AudioService.setInputMuted(!AudioService.inputMuted);
}
function togglePanel() {
root.screenDetector.withCurrentScreen(screen => {
var panel = PanelService.getPanel("audioPanel", screen);
panel?.toggle(null, "Volume");
});
}
function openPanel() {
root.screenDetector.withCurrentScreen(screen => {
var panel = PanelService.getPanel("audioPanel", screen);
panel?.open(null, "Volume");
});
}
function closePanel() {
root.screenDetector.withCurrentScreen(screen => {
var panel = PanelService.getPanel("audioPanel", screen);
panel?.close(null, "Volume");
});
}
}
IpcHandler {
target: "sessionMenu"
function toggle() {
root.screenDetector.withCurrentScreen(screen => {
var sessionMenuPanel = PanelService.getPanel("sessionMenuPanel", screen);
// Session Menu is never open near the bar
sessionMenuPanel?.toggle();
});
}
function lock() {
if (!PanelService.lockScreen.active) {
CompositorService.lock();
}
}
function lockAndSuspend() {
// Only lock and suspend if not already locked
if (!PanelService.lockScreen.active) {
CompositorService.lockAndSuspend();
}
}
}
IpcHandler {
target: "controlCenter"
function toggle() {
root.screenDetector.withCurrentScreen(screen => {
var controlCenterPanel = PanelService.getPanel("controlCenterPanel", screen);
if (Settings.data.controlCenter.position === "close_to_bar_button") {
// Will attempt to open the panel next to the bar button if any.
controlCenterPanel?.toggle(null, "ControlCenter");
} else {
controlCenterPanel?.toggle();
}
});
}
}
IpcHandler {
target: "dock"
function toggle() {
Settings.data.dock.enabled = !Settings.data.dock.enabled;
}
}
// Wallpaper IPC: trigger a new random wallpaper
IpcHandler {
target: "wallpaper"
function toggle() {
if (Settings.data.wallpaper.enabled) {
root.screenDetector.withCurrentScreen(screen => {
var wallpaperPanel = PanelService.getPanel("wallpaperPanel", screen);
wallpaperPanel?.toggle();
});
}
}
function random(screen: string) {
if (Settings.data.wallpaper.enabled) {
if (!screen || screen === "all" || screen.trim().length === 0) {
screen = undefined;
}
WallpaperService.setRandomWallpaper(screen);
}
}
function get(screen: string): string {
if (screen === "all" || screen === "") {
if (Quickshell.screens.length > 1) {
return JSON.stringify(WallpaperService.getWallpapersEffectiveMap());
}
return WallpaperService.getWallpaper(Quickshell.screens[0].name) ?? "";
} else {
var found = Quickshell.screens.find(s => s.name === screen);
if (!found) {
Logger.w("IPC", "wallpaper get: unknown screen: " + screen);
return "";
}
return WallpaperService.getWallpaper(screen) ?? "";
}
}
function set(path: string, screen: string) {
if (screen === "all" || screen === "") {
screen = undefined;
}
WallpaperService.changeWallpaper(path, screen);
}
function refresh() {
WallpaperService.refreshWallpapersList();
}
function toggleAutomation() {
Settings.data.wallpaper.automationEnabled = !Settings.data.wallpaper.automationEnabled;
}
function disableAutomation() {
Settings.data.wallpaper.automationEnabled = false;
}
function enableAutomation() {
Settings.data.wallpaper.automationEnabled = true;
}
}
IpcHandler {
target: "wifi"
function toggle() {
NetworkService.setWifiEnabled(!NetworkService.wifiEnabled);
}
function enable() {
NetworkService.setWifiEnabled(true);
}
function disable() {
NetworkService.setWifiEnabled(false);
}
}
IpcHandler {
target: "network"
function togglePanel() {
root.screenDetector.withCurrentScreen(screen => {
var networkPanel = PanelService.getPanel("networkPanel", screen);
networkPanel?.toggle(null, "Network");
});
}
}
IpcHandler {
target: "bluetooth"
function toggle() {
BluetoothService.setBluetoothEnabled(!BluetoothService.enabled);
}
function enable() {
BluetoothService.setBluetoothEnabled(true);
}
function disable() {
BluetoothService.setBluetoothEnabled(false);
}
function togglePanel() {
root.screenDetector.withCurrentScreen(screen => {
var bluetoothPanel = PanelService.getPanel("bluetoothPanel", screen);
bluetoothPanel?.toggle(null, "Bluetooth");
});
}
function toggleAutoConnect() {
Settings.data.network.bluetoothAutoConnect = !Settings.data.network.bluetoothAutoConnect;
}
function enableAutoConnect() {
Settings.data.network.bluetoothAutoConnect = true;
}
function disableAutoConnect() {
Settings.data.network.bluetoothAutoConnect = false;
}
}
IpcHandler {
target: "airplaneMode"
function toggle() {
NetworkService.setAirplaneMode(!NetworkService.airplaneModeEnabled);
}
function enable() {
NetworkService.setAirplaneMode(true);
}
function disable() {
NetworkService.setAirplaneMode(false);
}
}
IpcHandler {
target: "battery"
function togglePanel() {
root.screenDetector.withCurrentScreen(screen => {
var batteryPanel = PanelService.getPanel("batteryPanel", screen);
batteryPanel?.toggle(null, "Battery");
});
}
}
IpcHandler {
target: "powerProfile"
function cycle() {
PowerProfileService.cycleProfile();
}
function cycleReverse() {
PowerProfileService.cycleProfileReverse();
}
function set(mode: string) {
switch (mode) {
case "performance":
PowerProfileService.setProfile(2);
break;
case "balanced":
PowerProfileService.setProfile(1);
break;
case "powersaver":
PowerProfileService.setProfile(0);
break;
}
}
function toggleNoctaliaPerformance() {
PowerProfileService.toggleNoctaliaPerformance();
}
function enableNoctaliaPerformance() {
PowerProfileService.setNoctaliaPerformance(true);
}
function disableNoctaliaPerformance() {
PowerProfileService.setNoctaliaPerformance(false);
}
}
IpcHandler {
target: "media"
function toggle() {
root.screenDetector.withCurrentScreen(screen => {
var panel = PanelService.getPanel("mediaPlayerPanel", screen);
panel?.toggle(null, "MediaMini");
});
}
function playPause() {
MediaService.playPause();
}
function play() {
MediaService.play();
}
function stop() {
MediaService.stop();
}
function pause() {
MediaService.pause();
}
function next() {
MediaService.next();
}
function previous() {
MediaService.previous();
}
function seekRelative(offset: string) {
var offsetVal = parseFloat(offset);
if (Number.isNaN(offsetVal)) {
Logger.w("Media", "Argument to ipc call 'media seekRelative' must be a number");
return;
}
MediaService.seekRelative(offsetVal);
}
function seekByRatio(position: string) {
var positionVal = parseFloat(position);
if (Number.isNaN(positionVal)) {
Logger.w("Media", "Argument to ipc call 'media seekByRatio' must be a number");
return;
}
MediaService.seekByRatio(positionVal);
}
}
IpcHandler {
target: "state"
// Returns all settings and shell state as JSON
function all(): string {
try {
var snapshot = ShellState.buildStateSnapshot();
if (!snapshot) {
throw new Error("State snapshot unavailable");
}
return JSON.stringify(snapshot, null, 2);
} catch (error) {
Logger.e("IPC", "Failed to serialize state:", error);
return JSON.stringify({
"error": "Failed to serialize state: " + error
}, null, 2);
}
}
}
IpcHandler {
target: "desktopWidgets"
function toggle() {
Settings.data.desktopWidgets.enabled = !Settings.data.desktopWidgets.enabled;
}
function disable() {
Settings.data.desktopWidgets.enabled = false;
}
function enable() {
Settings.data.desktopWidgets.enabled = true;
}
function edit() {
DesktopWidgetRegistry.editMode = !DesktopWidgetRegistry.editMode;
}
}
IpcHandler {
target: "location"
function get(): string {
return Settings.data.location.name;
}
function set(name: string) {
Settings.data.location.name = name;
LocationService.update();
}
}
IpcHandler {
target: "systemMonitor"
function toggle() {
root.screenDetector.withCurrentScreen(screen => {
var panel = PanelService.getPanel("systemStatsPanel", screen);
panel?.toggle(null, "SystemMonitor");
});
}
}
IpcHandler {
target: "plugin"
function openSettings(key: string) {
var manifest = PluginRegistry.getPluginManifest(key);
if (!manifest) {
Logger.w("IPC", "Plugin not found:", key);
return;
}
if (!manifest.entryPoints?.settings) {
Logger.w("IPC", "Plugin has no settings entry point:", key);
return;
}
root.screenDetector.withCurrentScreen(screen => {
BarService.openPluginSettings(screen, manifest);
});
}
function openPanel(key: string) {
var manifest = PluginRegistry.getPluginManifest(key);
if (!manifest) {
Logger.w("IPC", "Plugin not found:", key);
return;
}
if (!manifest.entryPoints?.panel) {
Logger.w("IPC", "Plugin has no panel entry point:", key);
return;
}
root.screenDetector.withCurrentScreen(screen => {
PluginService.openPluginPanel(key, screen, null);
});
}
function closePanel(key: string) {
var manifest = PluginRegistry.getPluginManifest(key);
if (!manifest) {
Logger.w("IPC", "Plugin not found:", key);
return;
}
if (!manifest.entryPoints?.panel) {
Logger.w("IPC", "Plugin has no panel entry point:", key);
return;
}
root.screenDetector.withCurrentScreen(screen => {
var api = PluginService.getPluginAPI(key);
if (api) {
api.closePanel(screen);
}
});
}
function togglePanel(key: string) {
var manifest = PluginRegistry.getPluginManifest(key);
if (!manifest) {
Logger.w("IPC", "Plugin not found:", key);
return;
}
if (!manifest.entryPoints?.panel) {
Logger.w("IPC", "Plugin has no panel entry point:", key);
return;
}
root.screenDetector.withCurrentScreen(screen => {
PluginService.togglePluginPanel(key, screen, null);
});
}
}
}
@@ -0,0 +1,368 @@
pragma Singleton
import QtQml
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.UPower
import qs.Commons
import qs.Services.Networking
import qs.Services.UI
Singleton {
id: root
readonly property var primaryDevice: _laptopBattery || _bluetoothBattery || null // Primary battery device (prioritizes laptop over Bluetooth)
readonly property real batteryPercentage: getPercentage(primaryDevice)
readonly property bool batteryCharging: isCharging(primaryDevice)
readonly property bool batteryPluggedIn: isPluggedIn(primaryDevice)
readonly property bool batteryReady: isDeviceReady(primaryDevice)
readonly property bool batteryPresent: isDevicePresent(primaryDevice)
readonly property real warningThreshold: Settings.data.systemMonitor.batteryWarningThreshold
readonly property real criticalThreshold: Settings.data.systemMonitor.batteryCriticalThreshold
readonly property string batteryIcon: getIcon(batteryPercentage, batteryCharging, batteryPluggedIn, batteryReady)
readonly property var laptopBatteries: UPower.devices.values.filter(d => d.isLaptopBattery).sort((x, y) => {
// Force DisplayDevice to the top
if (x.nativePath.includes("DisplayDevice"))
return -1;
if (y.nativePath.includes("DisplayDevice"))
return 1;
// Standard string comparison works for BAT0 vs BAT1
return x.nativePath.localeCompare(y.nativePath, undefined, {
numeric: true
});
})
readonly property var bluetoothBatteries: {
var list = [];
var btArray = BluetoothService.devices?.values || [];
for (var i = 0; i < btArray.length; i++) {
var btd = btArray[i];
if (btd && btd.connected && btd.batteryAvailable) {
list.push(btd);
}
}
return list;
}
readonly property var _laptopBattery: UPower.displayDevice.isPresent ? UPower.displayDevice : (laptopBatteries.length > 0 ? laptopBatteries[0] : null)
readonly property var _bluetoothBattery: bluetoothBatteries.length > 0 ? bluetoothBatteries[0] : null
property var deviceModel: {
var model = [
{
"key": "__default__",
"name": I18n.tr("bar.battery.device-default")
}
];
const devices = UPower.devices?.values || [];
for (let d of devices) {
if (!d || d.type === UPowerDeviceType.LinePower) {
continue;
}
model.push({
key: d.nativePath || "",
name: d.model || d.nativePath || I18n.tr("common.unknown")
});
}
return model;
}
property var _hasNotified: ({})
function findDevice(nativePath) {
if (!nativePath || nativePath === "__default__" || nativePath === "DisplayDevice") {
return _laptopBattery;
}
if (!UPower.devices) {
return null;
}
const devices = UPower.devices?.values || [];
for (let d of devices) {
if (d && d.nativePath === nativePath) {
if (d.type === UPowerDeviceType.LinePower) {
continue;
}
return d;
}
}
return null;
}
function isDevicePresent(device) {
if (!device) {
return false;
}
// Handle Bluetooth devices (identified by having batteryAvailable property)
if (device.batteryAvailable !== undefined) {
return device.connected === true;
}
// Handle UPower devices
if (device.type !== undefined) {
if (device.type === UPowerDeviceType.Battery && device.isPresent !== undefined) {
return device.isPresent === true;
}
// Fallback for non-battery UPower devices or if isPresent is missing
return device.ready && device.percentage !== undefined;
}
return false;
}
function isDeviceReady(device) {
if (!isDevicePresent(device)) {
return false;
}
if (device.batteryAvailable !== undefined) {
return device.battery !== undefined;
}
return device.ready && device.percentage !== undefined;
}
function getPercentage(device) {
if (!device) {
return -1;
}
if (device.batteryAvailable !== undefined) {
return Math.round((device.battery || 0) * 100);
}
return Math.round((device.percentage || 0) * 100);
}
function isCharging(device) {
if (!device || isBluetoothDevice(device)) {
// Tracking bluetooth devices can charge or not is a loop hole, none of my devices has it, even if it possible?!
return false; // Assuming not charging until someone/quickshell brings a way to do pretty unlikely.
}
if (device.state !== undefined) {
return device.state === UPowerDeviceState.Charging;
}
return false;
}
function isPluggedIn(device) {
if (!device || isBluetoothDevice(device)) {
// Tracking bluetooth devices can charge or not is a loop hole, none of my devices has it, even if it possible?!
return false; // Assuming not charging until someone/quickshell brings a way to do pretty unlikely.
}
if (device.state !== undefined) {
return device.state === UPowerDeviceState.FullyCharged || device.state === UPowerDeviceState.PendingCharge;
}
return false;
}
function isCriticalBattery(device) {
return (!isCharging(device) && !isPluggedIn(device)) && getPercentage(device) <= criticalThreshold;
}
function isLowBattery(device) {
return (!isCharging(device) && !isPluggedIn(device)) && getPercentage(device) <= warningThreshold && getPercentage(device) > criticalThreshold;
}
function isBluetoothDevice(device) {
if (!device) {
return false;
}
// Check for Quickshell Bluetooth device property
if (device.batteryAvailable !== undefined) {
return true;
}
// Check for UPower device path indicating it's a Bluetooth device
if (device.nativePath && (device.nativePath.includes("bluez") || device.nativePath.includes("bluetooth"))) {
return true;
}
return false;
}
function getDeviceName(device) {
if (!isDeviceReady(device)) {
return "";
}
if (!isBluetoothDevice(device) && device.isLaptopBattery) {
// If there is more than one battery explicitly name them
// Logger.e("BatteryDebug", "Available Battery count: " + laptopBatteries.length); // can be useful for debugging
if (laptopBatteries.length > 1 && device.nativePath) {
if (device.nativePath === "DisplayDevice") {
return I18n.tr("battery.all-batteries");
}
var match = device.nativePath.match(/(\d+)$/);
if (match) {
// In case of 2 batteries: bat0 => bat1 bat1 => bat2
return I18n.tr("common.battery") + " " + (parseInt(match[1]) + 1); // Append numbers
}
}
// Return Battery if there is only one
return I18n.tr("common.battery");
}
if (isBluetoothDevice(device) && device.name) {
return device.name;
}
if (device.model) {
return device.model;
}
return "";
}
function getIcon(percent, charging, pluggedIn, isReady) {
if (!isReady) {
return "battery-exclamation";
}
if (charging) {
return "battery-charging";
}
if (pluggedIn) {
return "battery-charging-2";
}
const icons = [
{
threshold: 86,
icon: "battery-4"
},
{
threshold: 56,
icon: "battery-3"
},
{
threshold: 31,
icon: "battery-2"
},
{
threshold: 11,
icon: "battery-1"
},
{
threshold: 0,
icon: "battery"
}
];
const match = icons.find(tier => percent >= tier.threshold);
return match ? match.icon : "battery-off"; // New fallback icon clearly represent if nothing is true here.
}
function getRateText(device) {
if (!device || device.changeRate === undefined) {
return "";
}
const rate = Math.abs(device.changeRate);
if (device.timeToFull > 0) {
return I18n.tr("battery.charging-rate", {
"rate": rate.toFixed(2)
});
} else if (device.timeToEmpty > 0) {
return I18n.tr("battery.discharging-rate", {
"rate": rate.toFixed(2)
});
}
}
function getTimeRemainingText(device) {
if (!isDeviceReady(device)) {
return I18n.tr("battery.no-battery-detected");
}
if (isPluggedIn(device)) {
return I18n.tr("battery.plugged-in");
} else if (device.timeToFull > 0) {
return I18n.tr("battery.time-until-full", {
"time": Time.formatVagueHumanReadableDuration(device.timeToFull)
});
} else if (device.timeToEmpty > 0) {
return I18n.tr("battery.time-left", {
"time": Time.formatVagueHumanReadableDuration(device.timeToEmpty)
});
}
return I18n.tr("common.idle");
}
function checkDevice(device) {
if (!device || !isDeviceReady(device)) {
return;
}
const percentage = getPercentage(device);
const charging = isCharging(device);
const pluggedIn = isPluggedIn(device);
const level = isLowBattery(device) ? "low" : (isCriticalBattery(device) ? "critical" : "");
var deviceKey = device.nativePath;
if (!_hasNotified[deviceKey]) {
_hasNotified[deviceKey] = {
low: false,
critical: false
};
}
if (charging || pluggedIn) {
_hasNotified[deviceKey].low = false;
_hasNotified[deviceKey].critical = false;
}
if (percentage > warningThreshold) {
_hasNotified[deviceKey].low = false;
_hasNotified[deviceKey].critical = false;
} else if (percentage > criticalThreshold) {
_hasNotified[deviceKey].critical = false;
}
if (level) {
if (!_hasNotified[deviceKey][level]) {
notify(device, level);
_hasNotified[deviceKey][level] = true;
}
}
}
function notify(device, level) {
if (!Settings.data.notifications.enableBatteryToast) {
return;
}
var name = getDeviceName(device);
var titleKey = level === "critical" ? "toast.battery.critical" : "toast.battery.low";
var descKey = level === "critical" ? "toast.battery.critical-desc" : "toast.battery.low-desc";
var title = I18n.tr(titleKey);
var desc = I18n.tr(descKey, {
"percent": getPercentage(device)
});
var icon = level === "critical" ? "battery-exclamation" : "battery-charging-2";
if (device == _bluetoothBattery && name) {
title = title + " " + name;
}
// Only 'showNotice' supports custom icons
ToastService.showNotice(title, desc, icon, 6000);
}
Instantiator {
model: deviceModel
delegate: Connections {
required property var modelData
property var device: findDevice(modelData.key)
target: device
function onPercentageChanged() {
if (device.isLaptopBattery && modelData.key !== "__default__") {
return;
}
checkDevice(device);
}
function onStateChanged() {
if (device.isLaptopBattery && modelData.key !== "__default__") {
return;
}
checkDevice(device);
}
}
}
}
@@ -0,0 +1,560 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
Singleton {
id: root
property list<var> ddcMonitors: []
readonly property list<Monitor> monitors: variants.instances
property bool appleDisplayPresent: false
property list<var> availableBacklightDevices: []
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 (Settings.data.brightness.enableDdcSupport && 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 setBrightness(value: real): void {
monitors.forEach(m => m.setBrightnessDebounced(value));
}
function getDetectedDisplays(): list<var> {
return detectedDisplays;
}
function normalizeBacklightDevicePath(devicePath): string {
if (devicePath === undefined || devicePath === null)
return "";
var normalized = String(devicePath).trim();
if (normalized === "")
return "";
if (normalized.startsWith("/sys/class/backlight/"))
return normalized;
if (normalized.indexOf("/") === -1)
return "/sys/class/backlight/" + normalized;
return normalized;
}
function getBacklightDeviceName(devicePath): string {
var normalized = normalizeBacklightDevicePath(devicePath);
if (normalized === "")
return "";
var parts = normalized.split("/");
while (parts.length > 0 && parts[parts.length - 1] === "") {
parts.pop();
}
return parts.length > 0 ? parts[parts.length - 1] : "";
}
function getMappedBacklightDevice(outputName): string {
var normalizedOutput = String(outputName || "").trim();
if (normalizedOutput === "")
return "";
var mappings = Settings.data.brightness.backlightDeviceMappings || [];
for (var i = 0; i < mappings.length; i++) {
var mapping = mappings[i];
if (!mapping || typeof mapping !== "object")
continue;
if (String(mapping.output || "").trim() === normalizedOutput)
return normalizeBacklightDevicePath(mapping.device || "");
}
return "";
}
function setMappedBacklightDevice(outputName, devicePath): void {
var normalizedOutput = String(outputName || "").trim();
if (normalizedOutput === "")
return;
var normalizedDevicePath = normalizeBacklightDevicePath(devicePath);
var mappings = Settings.data.brightness.backlightDeviceMappings || [];
var nextMappings = [];
var replaced = false;
for (var i = 0; i < mappings.length; i++) {
var mapping = mappings[i];
if (!mapping || typeof mapping !== "object")
continue;
var mappingOutput = String(mapping.output || "").trim();
var mappingDevice = normalizeBacklightDevicePath(mapping.device || "");
if (mappingOutput === "" || mappingDevice === "")
continue;
if (mappingOutput === normalizedOutput) {
if (!replaced && normalizedDevicePath !== "") {
nextMappings.push({
"output": normalizedOutput,
"device": normalizedDevicePath
});
}
replaced = true;
} else {
nextMappings.push({
"output": mappingOutput,
"device": mappingDevice
});
}
}
if (!replaced && normalizedDevicePath !== "") {
nextMappings.push({
"output": normalizedOutput,
"device": normalizedDevicePath
});
}
Settings.data.brightness.backlightDeviceMappings = nextMappings;
}
function scanBacklightDevices(): void {
if (!scanBacklightProc.running)
scanBacklightProc.running = true;
}
reloadableId: "brightness"
Component.onCompleted: {
Logger.i("Brightness", "Service started");
scanBacklightDevices();
if (Settings.data.brightness.enableDdcSupport) {
ddcProc.running = true;
}
}
onMonitorsChanged: {
ddcMonitors = [];
scanBacklightDevices();
if (Settings.data.brightness.enableDdcSupport) {
ddcProc.running = true;
}
}
Connections {
target: Settings.data.brightness
function onEnableDdcSupportChanged() {
if (Settings.data.brightness.enableDdcSupport) {
// Re-detect DDC monitors when enabled
ddcMonitors = [];
ddcProc.running = true;
} else {
// Clear DDC monitors when disabled
ddcMonitors = [];
}
}
function onBacklightDeviceMappingsChanged() {
scanBacklightDevices();
for (var i = 0; i < monitors.length; i++) {
var m = monitors[i];
if (m && !m.isDdc && !m.isAppleDisplay)
m.initBrightness();
}
}
}
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 available internal backlight devices
Process {
id: scanBacklightProc
command: ["sh", "-c", "for dev in /sys/class/backlight/*; do if [ -f \"$dev/brightness\" ] && [ -f \"$dev/max_brightness\" ]; then echo \"$dev\"; fi; done"]
stdout: StdioCollector {
onStreamFinished: {
var data = text.trim();
if (data === "") {
root.availableBacklightDevices = [];
return;
}
var lines = data.split("\n");
var found = [];
var seen = ({});
for (var i = 0; i < lines.length; i++) {
var path = root.normalizeBacklightDevicePath(lines[i]);
if (path === "" || seen[path])
continue;
seen[path] = true;
found.push(path);
}
root.availableBacklightDevices = found;
}
}
}
// Detect DDC monitors
Process {
id: ddcProc
property list<var> ddcMonitors: []
command: ["ddcutil", "detect", "--enable-dynamic-sleep", "--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 connectorMatch = d.match(/DRM[_ ]connector:\s*card\d+-(.+)/);
var ddcModel = ddcModelMatch ? ddcModelMatch.length > 0 : false;
var model = modelMatch ? modelMatch[1] : "Unknown";
var bus = busMatch ? busMatch[1] : "Unknown";
var connector = connectorMatch ? connectorMatch[1].trim() : "";
Logger.i("Brightness", "Detected DDC Monitor:", model, "connector:", connector, "bus:", bus, "is DDC:", !ddcModel);
return {
"model": model,
"busNum": bus,
"connector": connector,
"isDdc": !ddcModel
};
});
root.ddcMonitors = ddcProc.ddcMonitors.filter(m => m.isDdc);
}
}
}
component Monitor: QtObject {
id: monitor
required property ShellScreen modelData
readonly property bool isDdc: Settings.data.brightness.enableDdcSupport && 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")
readonly property string method: isAppleDisplay ? "apple" : (isDdc ? "ddcutil" : "internal")
// Check if brightness control is available for this monitor
readonly property bool brightnessControlAvailable: {
if (isAppleDisplay)
return true;
if (isDdc)
return true;
// For internal displays, check if we have a brightness path
return brightnessPath !== "";
}
property real brightness
property real lastBrightness: 0
property real queuedBrightness: NaN
property bool commandRunning: false
// 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
property bool initInProgress: 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 newBrightness = NaN;
if (monitor.isAppleDisplay) {
// Apple display format: single integer (0-101)
var val = parseInt(dataText);
if (!isNaN(val)) {
newBrightness = val / 101;
}
} else if (monitor.isDdc) {
// DDC format: "VCP 10 C 100 100" (space-separated)
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.maxBrightness = max;
newBrightness = current / max;
}
}
} else {
// Internal display format: two lines (current\nmax)
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) {
newBrightness = current / max;
}
}
}
// Update if we got a valid brightness value
if (!isNaN(newBrightness) && (Math.abs(newBrightness - monitor.brightness) > 0.001 || monitor.brightness === 0)) {
monitor.brightness = newBrightness;
monitor.brightnessUpdated(monitor.brightness);
root.monitorBrightnessChanged(monitor, monitor.brightness);
Logger.d("Brightness", "Refreshed brightness from system:", monitor.modelData.name, monitor.brightness);
}
}
}
}
readonly property Process setBrightnessProc: Process {
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
monitor.commandRunning = false;
// If there's a queued brightness change, process it now
if (!isNaN(monitor.queuedBrightness)) {
Qt.callLater(() => {
monitor.setBrightness(monitor.queuedBrightness);
monitor.queuedBrightness = NaN;
});
}
}
}
// 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 && monitor.busNum !== "") {
// For DDC displays, get the current value
refreshProc.command = ["ddcutil", "-b", monitor.busNum, "--enable-dynamic-sleep", "--sleep-multiplier=0.05", "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;
}
//Logger.i("Brightness", "Raw brightness data for", monitor.modelData.name + ":", dataText)
if (monitor.isAppleDisplay) {
var val = parseInt(dataText);
if (!isNaN(val)) {
monitor.brightness = val / 101;
Logger.d("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.maxBrightness = max;
monitor.brightness = current / max;
Logger.d("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.d("Brightness", "Internal brightness:", current + "/" + max + " =", monitor.brightness);
Logger.d("Brightness", "Using backlight device:", monitor.backlightDevice);
}
} else {
monitor.backlightDevice = "";
monitor.brightnessPath = "";
monitor.maxBrightnessPath = "";
}
}
monitor.initInProgress = false;
}
}
onExited: (exitCode, exitStatus) => {
monitor.initInProgress = false;
}
}
readonly property real stepSize: Settings.data.brightness.brightnessStep / 100.0
readonly property real minBrightnessValue: (Settings.data.brightness.enforceMinimum ? 0.01 : 0.0)
// Timer for debouncing rapid changes
readonly property Timer timer: Timer {
interval: monitor.isDdc ? 250 : 33
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;
// Enforce minimum brightness if enabled
if (Settings.data.brightness.enforceMinimum && value < minBrightnessValue) {
setBrightnessDebounced(Math.max(stepSize, minBrightnessValue));
} else {
// Normal brightness increase
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(minBrightnessValue, Math.min(1, value));
var rounded = Math.round(value * 100);
// Always update internal value and trigger UI feedback immediately
monitor.brightness = value;
monitor.brightnessUpdated(value);
root.monitorBrightnessChanged(monitor, monitor.brightness);
if (timer.running) {
monitor.queuedBrightness = value;
return;
}
// If a command is already running, queue this value
if (monitor.commandRunning) {
monitor.queuedBrightness = value;
return;
}
// Execute the brightness change command
if (isAppleDisplay) {
monitor.commandRunning = true;
monitor.ignoreNextChange = true;
setBrightnessProc.command = ["asdbctl", "set", rounded];
setBrightnessProc.running = true;
} else if (isDdc && busNum !== "") {
monitor.commandRunning = true;
monitor.ignoreNextChange = true;
var ddcValue = Math.round(value * monitor.maxBrightness);
var ddcBus = busNum;
Qt.callLater(() => {
setBrightnessProc.command = ["ddcutil", "-b", ddcBus, "--noverify", "--async", "--enable-dynamic-sleep", "--sleep-multiplier=0.05", "setvcp", "10", ddcValue];
setBrightnessProc.running = true;
});
} else if (!isDdc) {
monitor.commandRunning = true;
monitor.ignoreNextChange = true;
var backlightDeviceName = root.getBacklightDeviceName(monitor.backlightDevice);
if (backlightDeviceName !== "") {
setBrightnessProc.command = ["brightnessctl", "-d", backlightDeviceName, "s", rounded + "%"];
} else {
setBrightnessProc.command = ["brightnessctl", "s", rounded + "%"];
}
setBrightnessProc.running = true;
}
}
function initBrightness(): void {
monitor.initInProgress = true;
if (isAppleDisplay) {
initProc.command = ["asdbctl", "get"];
initProc.running = true;
} else if (isDdc && busNum !== "") {
initProc.command = ["ddcutil", "-b", busNum, "--enable-dynamic-sleep", "--sleep-multiplier=0.05", "getvcp", "10", "--brief"];
initProc.running = true;
} else if (!isDdc) {
// Internal backlight: first try explicit output mapping, then fall back to first available.
var preferredDevicePath = root.getMappedBacklightDevice(modelData.name);
var probeScript = ["preferred=\"$1\"", "if [ -n \"$preferred\" ] && [ ! -d \"$preferred\" ]; then preferred=\"/sys/class/backlight/$preferred\"; fi", "selected=\"\"",
"if [ -n \"$preferred\" ] && [ -f \"$preferred/brightness\" ] && [ -f \"$preferred/max_brightness\" ]; then selected=\"$preferred\"; else for dev in /sys/class/backlight/*; do if [ -f \"$dev/brightness\" ] && [ -f \"$dev/max_brightness\" ]; then selected=\"$dev\"; break; fi; done; fi",
"if [ -n \"$selected\" ]; then echo \"$selected\"; cat \"$selected/brightness\"; cat \"$selected/max_brightness\"; fi"].join("; ");
initProc.command = ["sh", "-c", probeScript, "sh", preferredDevicePath];
initProc.running = true;
} else {
monitor.initInProgress = false;
}
}
onBusNumChanged: initBrightness()
onIsDdcChanged: initBrightness()
Component.onCompleted: initBrightness()
}
}
@@ -0,0 +1,536 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.UI
// Clipboard history service using cliphist + local content cache
Singleton {
id: root
// Public API
property bool active: Settings.data.appLauncher.enableClipboardHistory && cliphistAvailable
property bool loading: false
property var items: [] // [{id, preview, mime, isImage}]
// Check if cliphist is available on the system
property bool cliphistAvailable: false
property bool dependencyChecked: false
// Optional automatic watchers to feed cliphist DB
property bool autoWatch: true
property bool watchersStarted: false
// Expose decoded thumbnails by id and a revision to notify bindings
property var imageDataById: ({})
property var _imageDataInsertOrder: [] // insertion-order IDs for LRU eviction
readonly property int _imageDataMaxEntries: 20 // max decoded images held in RAM at once
property int revision: 0
// Local content cache - stores full text content by ID
// This avoids relying on cliphist decode which can be unreliable
property var contentCache: ({})
// Track the most recent clipboard content for instant access
property string _latestTextContent: ""
property string _latestTextId: ""
// Approximate first-seen timestamps for entries this session (seconds)
property var firstSeenById: ({})
// Internal: store callback for decode
property var _decodeCallback: null
property int _decodeRequestId: 0
// Queue for base64 decodes
property var _b64Queue: []
property var _b64CurrentCb: null
property string _b64CurrentMime: ""
property string _b64CurrentId: ""
signal listCompleted
// Check if cliphist is available
Component.onCompleted: {
checkCliphistAvailability();
}
// Check dependency availability
function checkCliphistAvailability() {
if (dependencyChecked)
return;
dependencyCheckProcess.command = ["sh", "-c", "command -v cliphist"];
dependencyCheckProcess.running = true;
}
// Process to check if cliphist is available
Process {
id: dependencyCheckProcess
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
root.dependencyChecked = true;
if (exitCode === 0) {
root.cliphistAvailable = true;
// Start watchers if feature is enabled
if (root.active) {
startWatchers();
}
} else {
root.cliphistAvailable = false;
// Show toast notification if feature is enabled but cliphist is missing
if (Settings.data.appLauncher.enableClipboardHistory) {
ToastService.showWarning(I18n.tr("toast.clipboard.unavailable"), I18n.tr("toast.clipboard.unavailable-desc"), 6000);
}
}
}
}
// Start/stop watchers when enabled changes
onActiveChanged: {
if (root.active) {
startWatchers();
} else {
stopWatchers();
loading = false;
items = [];
}
}
// Fallback: periodically refresh list so UI updates even if not in clip mode
Timer {
interval: 5000
repeat: true
running: root.active
onTriggered: list()
}
// Internal process objects
Process {
id: listProc
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
const out = String(stdout.text);
const lines = out.split('\n').filter(l => l.length > 0);
// cliphist list default format: "<id> <preview>" or "<id>\t<preview>"
const parsed = lines.map((l, i) => {
let id = "";
let preview = "";
const m = l.match(/^(\d+)\s+(.+)$/);
if (m) {
id = m[1];
preview = m[2];
} else {
const tab = l.indexOf('\t');
id = tab > -1 ? l.slice(0, tab) : l;
preview = tab > -1 ? l.slice(tab + 1) : "";
}
const lower = preview.toLowerCase();
const isImage = lower.startsWith("[image]") || lower.includes(" binary data ");
// Best-effort mime guess from preview
var mime = "text/plain";
if (isImage) {
if (lower.includes(" png"))
mime = "image/png";
else if (lower.includes(" jpg") || lower.includes(" jpeg"))
mime = "image/jpeg";
else if (lower.includes(" webp"))
mime = "image/webp";
else if (lower.includes(" gif"))
mime = "image/gif";
else
mime = "image/*";
}
// Record first seen time for new ids (approximate copy time)
if (!root.firstSeenById[id]) {
const assumedAge = i * 15 * 60;
root.firstSeenById[id] = Time.timestamp - assumedAge;
}
// Smart type detection
var contentType = "text";
if (isImage) {
contentType = "image";
} else {
const t = preview.trim();
const tLower = t.toLowerCase();
if (/^#([a-f0-9]{3}|[a-f0-9]{6}|[a-f0-9]{8})$/.test(tLower)) {
contentType = "color";
} else if (/^https?:\/\//i.test(t)) {
contentType = "link";
} else if (/^(\/|~\/|file:\/\/)/i.test(t) && !t.startsWith('//') && !t.includes('\n')) {
contentType = "file";
} else if ((t.includes('{') && t.includes('}') && (t.includes(';') || t.includes('='))) || t.includes('</') || t.includes('/>') || t.includes('=>') || t.includes('===') || t.includes('!==') || t.includes('::') || t.includes('->') ||
/^(?:const|let|var|function|class|struct|interface|type|enum|import|export|func|fn|pub|def|using|namespace|property|public|private|protected)\b/i.test(t) || /^(?:#include|#define|#\[|@|\/\/|\/\*|<\?|<html|<body|<!DOCTYPE)/i.test(t) || /\b(?:require\(|module\.exports)\b/i.test(t)) {
contentType = "code";
}
}
return {
"id": id,
"preview": preview,
"isImage": isImage,
"mime": mime,
"contentType": contentType
};
});
// Filter out browser junk when copying images
const filtered = parsed.filter(item => {
if (item.isImage)
return true;
const p = item.preview;
// Skip UTF-16 encoded text (has null bytes between chars), chromium browser artifact
const nullCount = (p.match(/\x00/g) || []).length;
if (nullCount > p.length * 0.2)
return false;
// Skip browser-generated HTML wrapper, firefox
if (p.toLowerCase().startsWith("<meta http-equiv="))
return false;
return true;
});
items = filtered;
loading = false;
// Try to capture current clipboard and associate with newest item
if (filtered.length > 0 && !filtered[0].isImage && !root.contentCache[filtered[0].id]) {
root.captureCurrentClipboard();
}
root.listCompleted();
}
}
Process {
id: decodeProc
property int requestId: 0
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
if (requestId === root._decodeRequestId && root._decodeCallback) {
const out = String(stdout.text);
try {
root._decodeCallback(out);
} finally {
root._decodeCallback = null;
}
}
}
}
Process {
id: copyProc
stdout: StdioCollector {}
}
Process {
id: pasteProc
stdout: StdioCollector {}
}
Process {
id: deleteProc
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
revision++;
Qt.callLater(() => list());
}
}
// Base64 decode pipeline (queued)
Process {
id: decodeB64Proc
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
const b64 = String(stdout.text).trim();
if (root._b64CurrentCb) {
const url = `data:${root._b64CurrentMime};base64,${b64}`;
try {
root._b64CurrentCb(url);
} catch (e) {}
}
if (root._b64CurrentId !== "") {
const entryId = root._b64CurrentId;
root.imageDataById[entryId] = `data:${root._b64CurrentMime};base64,${b64}`;
// Track insertion order and evict oldest entries beyond the cap
root._imageDataInsertOrder.push(entryId);
while (root._imageDataInsertOrder.length > root._imageDataMaxEntries) {
const evicted = root._imageDataInsertOrder.shift();
delete root.imageDataById[evicted];
}
root.revision += 1;
}
root._b64CurrentCb = null;
root._b64CurrentMime = "";
root._b64CurrentId = "";
Qt.callLater(root._startNextB64);
}
}
// Text watcher - stores to cliphist and triggers content capture
Process {
id: watchText
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
if (root.autoWatch && root.watchersStarted && Settings.data.appLauncher.clipboardWatchTextCommand.trim() !== "") {
watchTextRestartTimer.restart();
}
}
}
Timer {
id: watchTextRestartTimer
interval: 1000
repeat: false
onTriggered: {
if (root.autoWatch && root.watchersStarted)
watchText.running = true;
}
}
// Image watcher
Process {
id: watchImage
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
if (root.autoWatch && root.watchersStarted && Settings.data.appLauncher.clipboardWatchImageCommand.trim() !== "") {
watchImageRestartTimer.restart();
}
}
}
Timer {
id: watchImageRestartTimer
interval: 1000
repeat: false
onTriggered: {
if (root.autoWatch && root.watchersStarted)
watchImage.running = true;
}
}
// Capture current clipboard text when needed
Process {
id: captureTextProc
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
const content = String(stdout.text);
if (content.length > 0) {
root._latestTextContent = content;
// Associate with newest item if we have one
if (root.items.length > 0 && !root.items[0].isImage) {
const newestId = root.items[0].id;
if (!root.contentCache[newestId]) {
root.contentCache[newestId] = content;
root.revision++;
}
}
}
}
}
}
function startWatchers() {
if (!root.active || !autoWatch || watchersStarted || !root.cliphistAvailable)
return;
watchersStarted = true;
// Text watcher
watchText.command = ["sh", "-c", Settings.data.appLauncher.clipboardWatchTextCommand];
watchText.running = true;
// Image watcher
watchImage.command = ["sh", "-c", Settings.data.appLauncher.clipboardWatchImageCommand];
watchImage.running = true;
}
function stopWatchers() {
if (!watchersStarted)
return;
watchText.running = false;
watchImage.running = false;
watchersStarted = false;
}
// Capture current clipboard text and cache it
function captureCurrentClipboard() {
if (captureTextProc.running)
return;
captureTextProc.command = ["wl-paste", "--no-newline"];
captureTextProc.running = true;
}
function list(maxPreviewWidth) {
if (!root.active || !root.cliphistAvailable) {
return;
}
if (listProc.running)
return;
loading = true;
const width = maxPreviewWidth || 100;
listProc.command = ["cliphist", "list", "-preview-width", String(width)];
listProc.running = true;
}
// Get content for an ID - uses cache first, falls back to cliphist decode
function getContent(id) {
if (root.contentCache[id]) {
return root.contentCache[id];
}
return null;
}
// Async decode - checks cache first, then falls back to cliphist
function decode(id, cb) {
if (!root.cliphistAvailable) {
if (cb)
cb("");
return;
}
// Check cache first
const cached = root.contentCache[id];
if (cached) {
if (cb)
cb(cached);
return;
}
// Fall back to cliphist decode
if (decodeProc.running) {
decodeProc.running = false;
}
root._decodeRequestId++;
decodeProc.requestId = root._decodeRequestId;
root._decodeCallback = function (content) {
// Cache the result if successful
if (content && content.trim()) {
root.contentCache[id] = content;
}
if (cb)
cb(content);
};
const idStr = String(id);
decodeProc.command = ["cliphist", "decode", idStr];
decodeProc.running = true;
}
function decodeToDataUrl(id, mime, cb) {
if (!root.cliphistAvailable) {
if (cb)
cb("");
return;
}
// If cached, return immediately
if (root.imageDataById[id]) {
if (cb)
cb(root.imageDataById[id]);
return;
}
// Queue request; ensures single process handles sequentially
root._b64Queue.push({
"id": id,
"mime": mime || "image/*",
"cb": cb
});
if (!decodeB64Proc.running && root._b64CurrentCb === null) {
_startNextB64();
}
}
function getImageData(id) {
if (id === undefined) {
return null;
}
return root.imageDataById[id];
}
function _startNextB64() {
if (root._b64Queue.length === 0 || !root.cliphistAvailable)
return;
const job = root._b64Queue.shift();
root._b64CurrentCb = job.cb;
root._b64CurrentMime = job.mime;
root._b64CurrentId = job.id;
decodeB64Proc.command = ["sh", "-c", `cliphist decode ${job.id} | base64 -w 0`];
decodeB64Proc.running = true;
}
function copyToClipboard(id) {
if (!root.cliphistAvailable) {
return;
}
copyProc.command = ["sh", "-c", `cliphist decode ${id} | wl-copy`];
copyProc.running = true;
}
function pasteFromClipboard(id, mime) {
if (!root.cliphistAvailable) {
return;
}
const isImage = mime && mime.startsWith("image/");
const typeArg = isImage ? ` --type ${mime}` : "";
const pasteKeys = isImage ? "wtype -M ctrl -k v" : "wtype -M ctrl -M shift v";
const cmd = `cliphist decode ${id} | wl-copy${typeArg} && ${pasteKeys}`;
pasteProc.command = ["sh", "-c", cmd];
pasteProc.running = true;
}
function pasteText(text) {
if (!text)
return;
const escaped = text.replace(/'/g, "'\\''");
const cmd = `printf '%s' '${escaped}' | wl-copy && wtype -M ctrl -M shift v`;
pasteProc.command = ["sh", "-c", cmd];
pasteProc.running = true;
}
function deleteById(id) {
if (!root.cliphistAvailable) {
return;
}
if (deleteProc.running) {
return;
}
const idStr = String(id).trim();
// Remove from caches
delete root.contentCache[idStr];
delete root.imageDataById[idStr];
const orderIdx = root._imageDataInsertOrder.indexOf(idStr);
if (orderIdx !== -1)
root._imageDataInsertOrder.splice(orderIdx, 1);
deleteProc.command = ["sh", "-c", `echo ${idStr} | cliphist delete`];
deleteProc.running = true;
}
function wipeAll() {
if (!root.cliphistAvailable) {
return;
}
// Clear caches
root.contentCache = {};
root.imageDataById = {};
root._imageDataInsertOrder = [];
root._latestTextContent = "";
root._latestTextId = "";
Quickshell.execDetached(["cliphist", "wipe"]);
revision++;
Qt.callLater(() => list());
}
// Parse image metadata from cliphist preview string
function parseImageMeta(preview) {
const re = /\[\[\s*binary data\s+([\d\.]+\s*(?:KiB|MiB|GiB|B))\s+(\w+)\s+(\d+)x(\d+)\s*\]\]/i;
const match = (preview || "").match(re);
if (!match)
return null;
return {
"size": match[1],
"fmt": (match[2] || "").toUpperCase(),
"w": Number(match[3]),
"h": Number(match[4])
};
}
}
@@ -0,0 +1,314 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
// Manages emoji data loading, searching, and clipboard operations
Singleton {
id: root
property var emojis: []
property bool loaded: false
// Usage tracking for popular emojis
// Format: { "emoji": { count: number, lastUsed: timestamp } }
property var usageCounts: ({})
// File path for persisting usage data
readonly property string usageFilePath: Settings.cacheDir + "emoji_usage.json"
// Searches emojis based on query
function search(query) {
if (!loaded) {
return [];
}
if (!query || query.trim() === "") {
return emojis;
}
const terms = query.toLowerCase().split(" ").filter(t => t);
const results = emojis.filter(emoji => {
for (let term of terms) {
const emojiMatch = emoji.emoji.toLowerCase().includes(term);
const nameMatch = emoji.name.toLowerCase().includes(term);
const keywordMatch = emoji.keywords.some(kw => kw.toLowerCase().includes(term));
const categoryMatch = emoji.category.toLowerCase().includes(term);
if (!emojiMatch && !nameMatch && !keywordMatch && !categoryMatch) {
return false;
}
}
return true;
});
return results;
}
function _getPopularEmojis(limit) {
var emojisWithUsage = emojis.map(function (emoji) {
const usageData = usageCounts[emoji.emoji];
return {
emoji: emoji,
usageCount: usageData ? (usageData.count || 0) : 0,
lastUsed: usageData ? (usageData.lastUsed || 0) : 0
};
}).filter(function (item) {
return item.usageCount > 0;
});
// Sort by last used timestamp (descending - most recent first)
// Then by usage count if timestamps are equal
emojisWithUsage.sort(function (a, b) {
if (b.lastUsed !== a.lastUsed) {
return b.lastUsed - a.lastUsed;
}
if (b.usageCount !== a.usageCount) {
return b.usageCount - a.usageCount;
}
return a.emoji.name.localeCompare(b.emoji.name);
});
// Return the emoji objects limited by the specified count
return emojisWithUsage.slice(0, limit).map(function (item) {
return item.emoji;
});
}
function getCategoriesWithCounts() {
if (!loaded) {
return [];
}
var categoryCounts = {};
for (var i = 0; i < emojis.length; i++) {
var emoji = emojis[i];
var category = emoji.category || "other";
if (!categoryCounts[category]) {
categoryCounts[category] = 0;
}
categoryCounts[category]++;
}
var categories = [];
for (var cat in categoryCounts) {
categories.push({
name: cat,
count: categoryCounts[cat]
});
}
return categories;
}
function getEmojisByCategory(category) {
if (!loaded) {
return [];
}
if (category === "recent") {
return _getPopularEmojis(25);
}
return emojis.filter(function (emoji) {
return emoji.category === category;
});
}
// Record emoji usage
function recordUsage(emojiChar) {
if (emojiChar) {
const currentData = usageCounts[emojiChar] || {
count: 0,
lastUsed: 0
};
usageCounts[emojiChar] = {
count: currentData.count + 1,
lastUsed: Date.now()
};
_saveUsageData();
}
}
// Ensure usage file exists with default content
function _ensureUsageFileExists() {
Quickshell.execDetached(["sh", "-c", `mkdir -p "$(dirname "${root.usageFilePath}")" && echo '{}' > "${root.usageFilePath}"`]);
}
// File paths
readonly property string userEmojiPath: Settings.configDir + "emoji.json"
readonly property string builtinEmojiPath: `${Quickshell.shellDir}/Assets/Launcher/emoji.json`
// Internal data
property var _userEmojiData: []
property var _builtinEmojiData: []
property int _pendingLoads: 0
// Initialize on component completion
Component.onCompleted: {
_loadUsageData();
_loadEmojis();
}
// File loaders
FileView {
id: userEmojiFile
path: root.userEmojiPath
printErrors: false
watchChanges: false
onLoaded: {
try {
const content = text();
if (content) {
const parsed = JSON.parse(content);
_userEmojiData = Array.isArray(parsed) ? parsed : [];
} else {
_userEmojiData = [];
}
} catch (e) {
_userEmojiData = [];
}
_onLoadComplete();
}
onLoadFailed: function (error) {
_userEmojiData = [];
_onLoadComplete();
}
}
FileView {
id: builtinEmojiFile
path: root.builtinEmojiPath
printErrors: false
watchChanges: false
onLoaded: {
try {
const content = text();
if (content) {
const parsed = JSON.parse(content);
_builtinEmojiData = Array.isArray(parsed) ? parsed : [];
} else {
_builtinEmojiData = [];
}
} catch (e) {
_builtinEmojiData = [];
}
_onLoadComplete();
}
onLoadFailed: function (error) {
_builtinEmojiData = [];
_onLoadComplete();
}
}
// Load emoji files
function _loadEmojis() {
_pendingLoads = 2;
userEmojiFile.reload();
builtinEmojiFile.reload();
}
// Called when one file finishes loading
function _onLoadComplete() {
_pendingLoads--;
if (_pendingLoads <= 0) {
_finalizeEmojis();
}
}
// Merge and deduplicate emojis
function _finalizeEmojis() {
const emojiMap = new Map();
// Add built-in emojis first
for (const emoji of _builtinEmojiData) {
if (emoji && emoji.emoji) {
emojiMap.set(emoji.emoji, emoji);
}
}
// Add user emojis (override built-ins if duplicate)
for (const emoji of _userEmojiData) {
if (emoji && emoji.emoji) {
emojiMap.set(emoji.emoji, emoji);
}
}
emojis = Array.from(emojiMap.values());
loaded = true;
}
// FileView for usage data
FileView {
id: usageFile
path: root.usageFilePath
printErrors: false
watchChanges: false
onLoaded: {
try {
const content = text();
if (content && content.trim() !== "") {
const parsed = JSON.parse(content);
if (parsed && typeof parsed === 'object') {
root.usageCounts = parsed;
} else {
root.usageCounts = {};
}
} else {
root.usageCounts = {};
}
} catch (e) {
root.usageCounts = {};
}
}
onLoadFailed: function (error) {
root.usageCounts = {};
Qt.callLater(_ensureUsageFileExists);
}
}
// Timer for debouncing usage data saves
Timer {
id: saveTimer
interval: 1000
repeat: false
onTriggered: _doSaveUsageData()
}
// Load usage data
function _loadUsageData() {
usageFile.reload();
}
// Save usage data with debounce
function _saveUsageData() {
saveTimer.restart();
}
// Actually save usage data to file
function _doSaveUsageData() {
try {
const content = JSON.stringify(root.usageCounts);
Quickshell.execDetached(["sh", "-c", `mkdir -p "$(dirname "${root.usageFilePath}")" && echo '${content}' > "${root.usageFilePath}"`]);
} catch (e) {
Logger.e("EmojiService", "Failed to save usage data: " + e.message);
}
}
// Copies emoji to clipboard
function copy(emojiChar) {
if (emojiChar) {
recordUsage(emojiChar); // Record usage before copying
Quickshell.execDetached(["sh", "-c", `echo -n "${emojiChar}" | wl-copy`]);
}
}
}
@@ -0,0 +1,319 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Compositor
import qs.Services.UI
Singleton {
id: root
property string currentLayout: I18n.tr("common.unknown")
property string fullLayoutName: I18n.tr("common.unknown")
property string previousLayout: ""
property bool isInitialized: false
// Updates current layout from various format strings. Called by compositors
function setCurrentLayout(layoutString) {
root.fullLayoutName = layoutString || I18n.tr("common.unknown");
root.currentLayout = extractLayoutCode(layoutString);
}
// Extract layout code from various format strings
// Priority: variant > country code > language lookup > fallback
function extractLayoutCode(layoutString) {
if (!layoutString)
return I18n.tr("common.unknown");
const str = layoutString.toLowerCase();
// If it's already a short code (2-3 chars), return uppercase
if (/^[a-z]{2,3}(\+.*)?$/.test(str)) {
return str.split('+')[0].toUpperCase();
}
// Check for layout variants first - these are more meaningful than country codes
// when distinguishing between multiple layouts of the same language
for (const [pattern, display] of Object.entries(variantMap)) {
if (str.includes(pattern)) {
return display;
}
}
// Extract short code from parentheses like "English (US)"
const shortCodeMatch = str.match(/\(([a-z]{2,3})\)/i);
if (shortCodeMatch) {
return shortCodeMatch[1].toUpperCase();
}
// Check for language/country names in the language map
// First, try to match at the start of the string (the primary language)
for (const [lang, code] of Object.entries(languageMap)) {
if (str.startsWith(lang)) {
return code.toUpperCase();
}
}
// Then try word boundary matching anywhere in the string
for (const [lang, code] of Object.entries(languageMap)) {
const regex = new RegExp(`\\b${lang}\\b`);
if (regex.test(str)) {
return code.toUpperCase();
}
}
// If nothing matches, try first 2-3 characters if they look like a code
const codeMatch = str.match(/^([a-z]{2,3})/);
return codeMatch ? codeMatch[1].toUpperCase() : I18n.tr("common.unknown");
}
// Watch for layout changes and show toast
onCurrentLayoutChanged: {
// Update previousLayout after checking for changes
const layoutChanged = isInitialized && currentLayout !== previousLayout && currentLayout !== I18n.tr("common.unknown") && previousLayout !== "" && previousLayout !== I18n.tr("common.unknown");
if (layoutChanged) {
if (Settings.data.notifications.enableKeyboardLayoutToast) {
const message = I18n.tr("toast.keyboard-layout.changed", {
"layout": fullLayoutName
});
ToastService.showNotice(I18n.tr("toast.keyboard-layout.title"), message, "", 2000);
}
Logger.d("KeyboardLayout", "Layout changed from", previousLayout, "to", currentLayout);
}
// Update previousLayout for next comparison
previousLayout = currentLayout;
}
Component.onCompleted: {
Logger.i("KeyboardLayout", "Service started");
// Mark as initialized after a delay to allow first layout update to complete
// This prevents showing a toast on the initial load
initializationTimer.start();
}
Timer {
id: initializationTimer
interval: 2000 // Wait 2 seconds for first layout update to complete
onTriggered: {
isInitialized = true;
// Set previousLayout to current value after initialization
previousLayout = currentLayout;
Logger.d("KeyboardLayout", "Service initialized, current layout:", currentLayout);
}
}
// Layout variants - checked BEFORE country codes
// These display the variant name when it's more meaningful than the country
property var variantMap: {
// Alternative keyboard layouts
"colemak": "Colemak",
"dvorak": "Dvorak",
"workman": "Workman",
"programmer dvorak": "Dvk-P",
"norman": "Norman",
// International variants
"intl": "Intl",
"international": "Intl",
"altgr-intl": "Intl",
"with dead keys": "Dead",
// Common variants
"phonetic": "Phon",
"extended": "Ext",
"ergonomic": "Ergo",
"legacy": "Legacy",
// Input methods
"pinyin": "Pinyin",
"cangjie": "Cangjie",
"romaji": "Romaji",
"kana": "Kana"
}
// Language/country name to ISO code mapping
property var languageMap: {
// English variants
"english": "us",
"american": "us",
"united states": "us",
"us english": "us",
"british": "gb",
"united kingdom": "gb",
"english (uk)": "gb",
"canadian": "ca",
"canada": "ca",
"canadian english": "ca",
"australian": "au",
"australia": "au",
// Nordic countries
"swedish": "se",
"svenska": "se",
"sweden": "se",
"norwegian": "no",
"norsk": "no",
"norway": "no",
"danish": "dk",
"dansk": "dk",
"denmark": "dk",
"finnish": "fi",
"suomi": "fi",
"finland": "fi",
"icelandic": "is",
"íslenska": "is",
"iceland": "is",
// Western/Central European Germanic
"german": "de",
"deutsch": "de",
"germany": "de",
"austrian": "at",
"austria": "at",
"österreich": "at",
"swiss": "ch",
"switzerland": "ch",
"schweiz": "ch",
"suisse": "ch",
"dutch": "nl",
"nederlands": "nl",
"netherlands": "nl",
"holland": "nl",
"belgian": "be",
"belgium": "be",
"belgië": "be",
"belgique": "be",
// Romance languages (Western/Southern Europe)
"french": "fr",
"français": "fr",
"france": "fr",
"canadian french": "ca",
"spanish": "es",
"español": "es",
"spain": "es",
"castilian": "es",
"italian": "it",
"italiano": "it",
"italy": "it",
"portuguese": "pt",
"português": "pt",
"portugal": "pt",
"catalan": "ad",
"català": "ad",
"andorra": "ad",
// Eastern European Romance
"romanian": "ro",
"română": "ro",
"romania": "ro",
// Slavic languages (Eastern Europe)
"russian": "ru",
"русский": "ru",
"russia": "ru",
"polish": "pl",
"polski": "pl",
"poland": "pl",
"czech": "cz",
"čeština": "cz",
"czech republic": "cz",
"slovak": "sk",
"slovenčina": "sk",
"slovakia": "sk",
// Ukrainian
"ukraine": "ua",
"ukrainian": "ua",
"українська": "ua",
"bulgarian": "bg",
"български": "bg",
"bulgaria": "bg",
"serbian": "rs",
"srpski": "rs",
"serbia": "rs",
"croatian": "hr",
"hrvatski": "hr",
"croatia": "hr",
"slovenian": "si",
"slovenščina": "si",
"slovenia": "si",
"bosnian": "ba",
"bosanski": "ba",
"bosnia": "ba",
"macedonian": "mk",
"македонски": "mk",
"macedonia": "mk",
// Celtic languages (Western Europe)
"irish": "ie",
"gaeilge": "ie",
"ireland": "ie",
"welsh": "gb",
"cymraeg": "gb",
"wales": "gb",
"scottish": "gb",
"gàidhlig": "gb",
"scotland": "gb",
// Baltic languages (Northern Europe)
"estonian": "ee",
"eesti": "ee",
"estonia": "ee",
"latvian": "lv",
"latviešu": "lv",
"latvia": "lv",
"lithuanian": "lt",
"lietuvių": "lt",
"lithuania": "lt",
// Other European languages
"hungarian": "hu",
"magyar": "hu",
"hungary": "hu",
"greek": "gr",
"ελληνικά": "gr",
"greece": "gr",
"albanian": "al",
"shqip": "al",
"albania": "al",
"maltese": "mt",
"malti": "mt",
"malta": "mt",
// West/Southwest Asian languages
"turkish": "tr",
"türkçe": "tr",
"turkey": "tr",
"arabic": "ar",
"العربية": "ar",
"arab": "ar",
"hebrew": "il",
"עברית": "il",
"israel": "il",
// South American languages
"brazilian": "br",
"brazilian portuguese": "br",
"brasil": "br",
"brazil": "br",
// East Asian languages
"japanese": "jp",
"日本語": "jp",
"japan": "jp",
"korean": "kr",
"한국어": "kr",
"korea": "kr",
"south korea": "kr",
"chinese": "cn",
"中文": "cn",
"china": "cn",
"simplified chinese": "cn",
"traditional chinese": "tw",
"taiwan": "tw",
"繁體中文": "tw",
// Southeast Asian languages
"thai": "th",
"ไทย": "th",
"thailand": "th",
"vietnamese": "vn",
"tiếng việt": "vn",
"vietnam": "vn",
// South Asian languages
"hindi": "in",
"हिन्दी": "in",
"india": "in",
// African languages
"afrikaans": "za",
"south africa": "za",
"south african": "za"
}
}
@@ -0,0 +1,151 @@
pragma Singleton
import Qt.labs.folderlistmodel 2.10
import QtQml.Models
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
Singleton {
id: root
// Component registration - only poll when something needs lock key state
function registerComponent(componentId) {
root._registered[componentId] = true;
root._registered = Object.assign({}, root._registered);
Logger.d("LockKeys", "Component registered:", componentId, "- total:", root._registeredCount);
}
function unregisterComponent(componentId) {
delete root._registered[componentId];
root._registered = Object.assign({}, root._registered);
Logger.d("LockKeys", "Component unregistered:", componentId, "- total:", root._registeredCount);
}
property var _registered: ({})
readonly property int _registeredCount: Object.keys(_registered).length
readonly property bool shouldRun: _registeredCount > 0
property bool capsLockOn: false
property bool numLockOn: false
property bool scrollLockOn: false
signal capsLockChanged(bool active)
signal numLockChanged(bool active)
signal scrollLockChanged(bool active)
Instantiator {
model: FolderListModel {
id: folderModel
folder: Qt.resolvedUrl("/sys/class/leds")
showFiles: false
showOnlyReadable: true
}
delegate: Component {
FileView {
id: fileView
path: filePath + "/brightness"
// sysfs brightness can fail transiently (e.g. resume); omit console spam like other sysfs FileViews.
printErrors: false
watchChanges: false
function parseBrightnessLedOn(raw) {
var t = raw.trim();
if (t === "" || !/^[0-9]+$/.test(t))
return null;
return parseInt(t, 10) !== 0;
}
function applyLockState(kind, state, emitIfChanged) {
switch (kind) {
case "numlock":
if (emitIfChanged && root.numLockOn !== state) {
root.numLockOn = state;
root.numLockChanged(state);
Logger.i("LockKeysService", "Num Lock:", state, fileView.path);
} else if (!emitIfChanged) {
root.numLockOn = state;
}
break;
case "capslock":
if (emitIfChanged && root.capsLockOn !== state) {
root.capsLockOn = state;
root.capsLockChanged(state);
Logger.i("LockKeysService", "Caps Lock:", state, fileView.path);
} else if (!emitIfChanged) {
root.capsLockOn = state;
}
break;
case "scrolllock":
if (emitIfChanged && root.scrollLockOn !== state) {
root.scrollLockOn = state;
root.scrollLockChanged(state);
Logger.i("LockKeysService", "Scroll Lock:", state, fileView.path);
} else if (!emitIfChanged) {
root.scrollLockOn = state;
}
break;
}
}
// Only apply after a successful read — failed reloads must not update UI (empty text is not "off").
onLoaded: {
if (!fileView.isWanted)
return;
var state = fileView.parseBrightnessLedOn(fileView.text());
if (state === null)
return;
var kind = fileName.split("::")[1];
// First read after polling starts: sync bar/UI from sysfs without firing
// *Changed signals (OSD listens to those and would flash on startup).
if (!fileView.initialCheckDone) {
fileView.initialCheckDone = true;
fileView.applyLockState(kind, state, false);
return;
}
fileView.applyLockState(kind, state, true);
}
// FolderListModel only provides filters for file names, not folders
property bool isWanted: {
if (fileName.startsWith("input") && fileName.includes("::")) {
switch (fileName.split("::")[1]) {
case "numlock":
case "capslock":
case "scrolllock":
return true;
}
}
return false;
}
// After shouldRun becomes true, first brightness read updates properties only (no *Changed signals).
property bool initialCheckDone: false
property variant connections: Connections {
target: root
function onShouldRunChanged() {
if (root.shouldRun) {
fileView.initialCheckDone = false;
}
}
}
// sysfs does not provide change notifications
property variant refreshTimer: Timer {
interval: 200
running: root.shouldRun && fileView.isWanted
repeat: true
onTriggered: fileView.reload()
}
}
}
}
Component.onCompleted: {
Logger.i("LockKeysService", "Service started (polling deferred until a consumer registers).");
}
}
@@ -0,0 +1,148 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Location
Singleton {
id: root
// Python scripts
readonly property string checkCalendarAvailableScript: Quickshell.shellDir + '/Scripts/python/src/calendar/check-calendar.py'
readonly property string listCalendarsScript: Quickshell.shellDir + '/Scripts/python/src/calendar/list-calendars.py'
readonly property string calendarEventsScript: Quickshell.shellDir + '/Scripts/python/src/calendar/calendar-events.py'
function init() {
availabilityCheckProcess.running = true;
}
function loadCalendars() {
listCalendarsProcess.running = true;
}
function loadEvents(daysAhead = 31, daysBehind = 14) {
CalendarService.loading = true;
CalendarService.lastError = "";
const now = new Date();
const startDate = new Date(now.getTime() - (daysBehind * 24 * 60 * 60 * 1000));
const endDate = new Date(now.getTime() + (daysAhead * 24 * 60 * 60 * 1000));
loadEventsProcess.startTime = Math.floor(startDate.getTime() / 1000);
loadEventsProcess.endTime = Math.floor(endDate.getTime() / 1000);
loadEventsProcess.running = true;
Logger.d("Calendar", `Loading events (${daysBehind} days behind, ${daysAhead} days ahead): ${startDate.toLocaleDateString()} to ${endDate.toLocaleDateString()}`);
}
// Process to check for evolution-data-server libraries
Process {
id: availabilityCheckProcess
running: false
command: ["sh", "-c", "command -v python3 >/dev/null 2>&1 && python3 " + root.checkCalendarAvailableScript + " || echo 'unavailable: python3 not installed'"]
stdout: StdioCollector {
onStreamFinished: {
const result = text.trim();
if (result === "available") {
CalendarService.dataProvider = CalendarService.dataProvider || root;
CalendarService.available = true;
}
if (CalendarService.available) {
Logger.i("Calendar", "EDS libraries available");
loadCalendars();
} else {
Logger.w("Calendar", "EDS libraries not available: " + result);
CalendarService.lastError = "Evolution Data Server libraries not installed";
}
}
}
stderr: StdioCollector {
onStreamFinished: {
if (text.trim()) {
Logger.d("Calendar", "Availability check error: " + text);
CalendarService.lastError = "Failed to check library availability";
}
}
}
}
// Process to list available calendars
Process {
id: listCalendarsProcess
running: false
command: ["python3", root.listCalendarsScript]
stdout: StdioCollector {
onStreamFinished: {
try {
const result = JSON.parse(text.trim());
CalendarService.setCalendars(result);
Logger.d("Calendar", `Found ${result.length} calendar(s)`);
// Auto-load events after discovering calendars
if (result.length > 0) {
loadEvents();
}
} catch (e) {
Logger.d("Calendar", "Failed to parse calendars: " + e);
CalendarService.lastError = "Failed to parse calendar list";
}
}
}
stderr: StdioCollector {
onStreamFinished: {
if (text.trim()) {
Logger.d("Calendar", "List calendars error: " + text);
CalendarService.lastError = text.trim();
}
}
}
}
// Process to load events
Process {
id: loadEventsProcess
running: false
property int startTime: 0
property int endTime: 0
command: ["python3", root.calendarEventsScript, startTime.toString(), endTime.toString()]
stdout: StdioCollector {
onStreamFinished: {
CalendarService.loading = false;
try {
const events = JSON.parse(text.trim());
CalendarService.setEvents(events);
Logger.d("Calendar", `Loaded ${events.length} events(s)`);
} catch (e) {
Logger.d("Calendar", "Failed to parse events: " + e);
CalendarService.lastError = "Failed to parse events";
// Fall back to cached events
CalendarService.loadCachedEvents();
Logger.d("Calendar", "Using cached events");
}
}
}
stderr: StdioCollector {
onStreamFinished: {
CalendarService.loading = false;
if (text.trim()) {
Logger.d("Calendar", "Load events error: " + text);
CalendarService.lastError = text.trim();
// Fall back to cached events
CalendarService.loadCachedEvents();
Logger.d("Calendar", "Using cached events");
}
}
}
}
}
@@ -0,0 +1,182 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Location
Singleton {
id: root
readonly property string khalEventsScript: Quickshell.shellDir + '/Scripts/python/src/calendar/khal-events.py'
function init() {
availabilityCheckProcess.running = true;
}
function loadCalendars() {
listCalendarsProcess.running = true;
}
function loadEvents(daysAhead = 31, daysBehind = 14) {
CalendarService.loading = true;
CalendarService.lastError = "";
const now = new Date();
const startDate = new Date(now.getTime() - (daysBehind * 24 * 60 * 60 * 1000));
loadEventsProcess.startTime = formatDateYMD(startDate);
loadEventsProcess.duration = `${daysAhead + daysBehind}d`;
loadEventsProcess.running = true;
Logger.d("Calendar", `Loading events (${daysBehind} days behind, ${daysAhead} days ahead): ${loadEventsProcess.startTime} ${loadEventsProcess.duration}`);
}
// Process to check for khal installation
Process {
id: availabilityCheckProcess
running: false
command: ["sh", "-c", "command -v khal >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1"]
onExited: function (exitCode) {
if (exitCode === 0) {
CalendarService.available = true;
CalendarService.dataProvider = CalendarService.dataProvider || root;
}
if (CalendarService.available) {
Logger.i("Calendar", "Khal available");
loadCalendars();
} else {
Logger.w("Calendar", "Khal not available");
CalendarService.lastError = "khal binary not installed";
}
}
}
// Process to list available calendars
Process {
id: listCalendarsProcess
running: false
command: ["khal", "printcalendars"]
stdout: StdioCollector {
onStreamFinished: {
try {
const calendars = text.trim().split("\n");
CalendarService.setCalendars(calendars);
Logger.d("Calendar", `Found ${calendars.length} calendar(s)`);
// Auto-load events after discovering calendars
if (calendars.length > 0) {
loadEvents();
}
} catch (e) {
Logger.d("Calendar", "Failed to parse calendars: " + e);
CalendarService.lastError = "Failed to parse calendar list";
}
}
}
stderr: StdioCollector {
onStreamFinished: {
if (text.trim()) {
Logger.d("Calendar", "List calendars error: " + text);
CalendarService.lastError = text.trim();
}
}
}
}
// Process to load events
Process {
id: loadEventsProcess
running: false
property string startTime: ""
property string duration: ""
command: ["python3", root.khalEventsScript, startTime, duration]
stdout: StdioCollector {
onStreamFinished: {
CalendarService.loading = false;
try {
const events = parseEvents(text.trim());
CalendarService.setEvents(events);
Logger.d("Calendar", `Loaded ${events.length} events(s)`);
} catch (e) {
Logger.d("Calendar", "Failed to parse events: " + e);
CalendarService.lastError = "Failed to parse events";
// Fall back to cached events
CalendarService.loadCachedEvents();
Logger.d("Calendar", "Using cached events");
}
}
}
stderr: StdioCollector {
onStreamFinished: {
CalendarService.loading = false;
if (text.trim()) {
Logger.d("Calendar", "Load events error: " + text);
CalendarService.lastError = text.trim();
// Fall back to cached events
CalendarService.loadCachedEvents();
Logger.d("Calendar", "Using cached events");
}
}
}
}
function parseEvents(text) {
const result = [];
const duplicates = new Set();
for (const line of text.split("\n")) {
if (!line.trim())
continue;
const dayEvents = JSON.parse(line);
for (const event of dayEvents) {
if (event["repeat-pattern"] !== "") {
// if there is a repeat pattern, the event must be included each time
result.push({
uid: event.uid,
calendar: event.calendar,
summary: event.title,
start: parseTimestamp(event["start-long-full"]),
end: parseTimestamp(event["end-long-full"]),
location: event.location,
description: event.description
});
} else if (!duplicates.has(event.uid)) {
// in any other cases, we must remove duplicates using the uid of the event
result.push({
uid: event.uid,
calendar: event.calendar,
summary: event.title,
start: parseTimestamp(event["start-long-full"]),
end: parseTimestamp(event["end-long-full"]),
location: event.location,
description: event.description
});
duplicates.add(event.uid);
}
}
}
return result;
}
function parseTimestamp(timeStr) {
// expects ISO8601 format
return Math.floor((Date.parse(timeStr)).valueOf() / 1000);
}
function formatDateYMD(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return y + "-" + m + "-" + d;
}
}
@@ -0,0 +1,136 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Location.Calendar
import qs.Services.System
Singleton {
id: root
// Core state
property var events: ([])
property bool loading: false
property bool available: false
property string lastError: ""
property var calendars: ([])
property var dataProvider: null
// Persistent cache
property string cacheFile: Settings.cacheDir + "calendar.json"
// Cache file handling
FileView {
id: cacheFileView
path: root.cacheFile
printErrors: false
JsonAdapter {
id: cacheAdapter
property var cachedEvents: ([])
property var cachedCalendars: ([])
property string lastUpdate: ""
}
onLoadFailed: {
cacheAdapter.cachedEvents = ([]);
cacheAdapter.cachedCalendars = ([]);
cacheAdapter.lastUpdate = "";
}
onLoaded: {
loadFromCache();
}
}
Component.onCompleted: {
Logger.i("Calendar", "Service started");
loadFromCache();
checkAvailability();
}
// Save cache with debounce
Timer {
id: saveDebounce
interval: 1000
onTriggered: cacheFileView.writeAdapter()
}
function setEvents(newEvents) {
root.events = newEvents;
cacheAdapter.cachedEvents = newEvents;
cacheAdapter.lastUpdate = new Date().toISOString();
saveCache();
}
function setCalendars(newCalendars) {
root.calendars = newCalendars;
cacheAdapter.cachedCalendars = newCalendars;
saveCache();
}
function loadCachedEvents() {
if (cacheAdapter.cachedEvents.length > 0) {
root.events = cacheAdapter.cachedEvents;
}
}
function saveCache() {
saveDebounce.restart();
}
// Load events and calendars from cache
function loadFromCache() {
if (cacheAdapter.cachedEvents && cacheAdapter.cachedEvents.length > 0) {
root.events = cacheAdapter.cachedEvents;
Logger.d("Calendar", `Loaded ${cacheAdapter.cachedEvents.length} cached event(s)`);
}
if (cacheAdapter.cachedCalendars && cacheAdapter.cachedCalendars.length > 0) {
root.calendars = cacheAdapter.cachedCalendars;
Logger.d("Calendar", `Loaded ${cacheAdapter.cachedCalendars.length} cached calendar(s)`);
}
if (cacheAdapter.lastUpdate) {
Logger.d("Calendar", `Cache last updated: ${cacheAdapter.lastUpdate}`);
}
}
// Auto-refresh timer (every 5 minutes)
Timer {
id: refreshTimer
interval: 300000
running: true
repeat: true
onTriggered: loadEvents()
}
// Core functions
function checkAvailability() {
if (!Settings.data.location.showCalendarEvents) {
root.available = false;
return;
}
Khal.init();
EvolutionDataServer.init();
}
function loadCalendars() {
if (!root.available || !dataProvider) {
return;
}
dataProvider.loadCalendars();
}
function loadEvents(daysAhead = 31, daysBehind = 14) {
if (!root.available || !dataProvider) {
return;
}
dataProvider.loadEvents(daysAhead, daysBehind);
}
}
@@ -0,0 +1,195 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Commons
Singleton {
id: root
property bool initComplete: false
property bool nextDarkModeState: false
Connections {
target: LocationService.data
enabled: Settings.data.colorSchemes.schedulingMode == "location"
function onWeatherChanged() {
if (LocationService.data.weather !== null) {
const changes = root.collectWeatherChanges(LocationService.data.weather);
if (!root.initComplete) {
root.initComplete = true;
root.applyCurrentMode(changes);
}
root.scheduleNextMode(changes);
}
}
}
Connections {
target: Settings.data.colorSchemes
enabled: Settings.data.colorSchemes.schedulingMode == "manual"
function onManualSunriseChanged() {
const changes = root.collectManualChanges();
root.applyCurrentMode(changes);
root.scheduleNextMode(changes);
}
function onManualSunsetChanged() {
const changes = root.collectManualChanges();
root.applyCurrentMode(changes);
root.scheduleNextMode(changes);
}
}
Connections {
target: Settings.data.colorSchemes
function onSchedulingModeChanged() {
root.update();
}
}
Connections {
target: Time
function onResumed() {
Logger.i("DarkModeService", "System resumed - re-evaluating dark mode");
root.update();
resumeRetryTimer.restart();
}
}
Timer {
id: timer
onTriggered: {
Settings.data.colorSchemes.darkMode = root.nextDarkModeState;
root.update();
}
}
Timer {
id: resumeRetryTimer
interval: 2000
repeat: false
onTriggered: {
Logger.i("DarkModeService", "Resume retry - re-evaluating dark mode again");
root.update();
}
}
function init() {
Logger.i("DarkModeService", "Service started");
root.update();
}
function update() {
if (Settings.data.colorSchemes.schedulingMode == "manual") {
const changes = collectManualChanges();
initComplete = true;
applyCurrentMode(changes);
scheduleNextMode(changes);
} else if (Settings.data.colorSchemes.schedulingMode == "location" && LocationService.data.weather) {
const changes = collectWeatherChanges(LocationService.data.weather);
initComplete = true;
applyCurrentMode(changes);
scheduleNextMode(changes);
}
}
function parseTime(timeString) {
const parts = timeString.split(":").map(Number);
return {
"hour": parts[0],
"minute": parts[1]
};
}
function collectManualChanges() {
const sunriseTime = parseTime(Settings.data.colorSchemes.manualSunrise);
const sunsetTime = parseTime(Settings.data.colorSchemes.manualSunset);
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const yesterdaysSunset = new Date(year, month, day - 1, sunsetTime.hour, sunsetTime.minute);
const todaysSunrise = new Date(year, month, day, sunriseTime.hour, sunriseTime.minute);
const todaysSunset = new Date(year, month, day, sunsetTime.hour, sunsetTime.minute);
const tomorrowsSunrise = new Date(year, month, day + 1, sunriseTime.hour, sunriseTime.minute);
return [
{
"time": yesterdaysSunset.getTime(),
"darkMode": true
},
{
"time": todaysSunrise.getTime(),
"darkMode": false
},
{
"time": todaysSunset.getTime(),
"darkMode": true
},
{
"time": tomorrowsSunrise.getTime(),
"darkMode": false
}
];
}
function collectWeatherChanges(weather) {
const changes = [];
if (Date.now() < Date.parse(weather.daily.sunrise[0])) {
// The sun has not risen yet
changes.push({
"time": Date.now() - 1,
"darkMode": true
});
}
for (var i = 0; i < weather.daily.sunrise.length; i++) {
changes.push({
"time": Date.parse(weather.daily.sunrise[i]),
"darkMode": false
});
changes.push({
"time": Date.parse(weather.daily.sunset[i]),
"darkMode": true
});
}
return changes;
}
function applyCurrentMode(changes) {
const now = Date.now();
Logger.i("DarkModeService", `Applying mode at ${new Date(now).toLocaleString()} (${now})`);
// changes.findLast(change => change.time < now) // not available in QML...
let lastChange = null;
for (var i = 0; i < changes.length; i++) {
Logger.d("DarkModeService", `Checking change: time=${changes[i].time} (${new Date(changes[i].time).toLocaleString()}), darkMode=${changes[i].darkMode}`);
if (changes[i].time < now) {
lastChange = changes[i];
}
}
if (lastChange) {
Logger.i("DarkModeService", `Selected change: time=${lastChange.time}, darkMode=${lastChange.darkMode}`);
Settings.data.colorSchemes.darkMode = lastChange.darkMode;
Logger.d("DarkModeService", `Reset: darkmode=${lastChange.darkMode}`);
} else {
Logger.w("DarkModeService", "No suitable change found for current time!");
}
}
function scheduleNextMode(changes) {
const now = Date.now();
const nextChange = changes.find(change => change.time > now);
if (nextChange) {
root.nextDarkModeState = nextChange.darkMode;
timer.interval = nextChange.time - now;
timer.restart();
Logger.d("DarkModeService", `Scheduled: darkmode=${nextChange.darkMode} in ${timer.interval} ms`);
}
}
}
@@ -0,0 +1,397 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
// Location and weather service with decoupled geocoding and weather fetching.
Singleton {
id: root
property string locationFile: Quickshell.env("NOCTALIA_WEATHER_FILE") || (Settings.cacheDir + "location.json")
property int weatherUpdateFrequency: 30 * 60
property bool isFetchingWeather: false
// Talia weather
readonly property int taliaMascotWeatherMonth: 3
readonly property int taliaMascotWeatherDay: 1
readonly property bool taliaWeatherMascotDayActive: {
const d = Time.now;
return d.getMonth() === root.taliaMascotWeatherMonth && d.getDate() === root.taliaMascotWeatherDay;
}
readonly property bool taliaWeatherMascotActive: taliaWeatherMascotDayActive || Settings.data.location.weatherTaliaMascotAlways
readonly property alias data: adapter
// True when the user has set a location name or enabled auto-locate
readonly property bool locationConfigured: Settings.data.location.name !== "" || Settings.data.location.autoLocate
// Stable UI properties - only updated when location is successfully geocoded
property bool coordinatesReady: false
property string stableLatitude: ""
property string stableLongitude: ""
property string stableName: ""
FileView {
id: locationFileView
path: locationFile
printErrors: false
onAdapterUpdated: saveTimer.start()
onLoaded: {
Logger.d("Location", "Loaded cached data");
if (adapter.latitude !== "" && adapter.longitude !== "" && adapter.weatherLastFetch > 0) {
root.stableLatitude = adapter.latitude;
root.stableLongitude = adapter.longitude;
root.stableName = adapter.name;
root.coordinatesReady = true;
Logger.i("Location", "Coordinates ready");
}
update();
}
onLoadFailed: function (error) {
update();
}
JsonAdapter {
id: adapter
property string latitude: ""
property string longitude: ""
property string name: ""
property int weatherLastFetch: 0
property var weather: null
}
}
// Formatted coordinates for UI display
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}`;
}
// Auto-geolocate timer - periodically updates location via IP geolocation
Timer {
id: autoLocateTimer
interval: 30 * 60 * 1000
running: Settings.data.location.autoLocate
repeat: true
triggeredOnStart: true
onTriggered: root.geolocateAndApply()
}
// Update timer runs when weather is enabled or location-based scheduling is active
Timer {
id: updateTimer
interval: 20 * 1000
running: Settings.data.location.weatherEnabled || Settings.data.colorSchemes.schedulingMode == "location"
repeat: true
onTriggered: {
update();
}
}
Timer {
id: saveTimer
running: false
interval: 1000
onTriggered: locationFileView.writeAdapter()
}
function init() {
Logger.i("Location", "Service started");
}
function resetWeather() {
Logger.i("Location", "Resetting location and weather data");
root.coordinatesReady = false;
root.stableLatitude = "";
root.stableLongitude = "";
root.stableName = "";
adapter.latitude = "";
adapter.longitude = "";
adapter.name = "";
adapter.weatherLastFetch = 0;
adapter.weather = null;
isFetchingWeather = false;
update();
}
// Main update function - geocodes location if needed, then fetches weather if enabled
function update() {
updateLocation();
if (Settings.data.location.weatherEnabled) {
updateWeatherData();
}
}
// Runs independently of weather toggle
function updateLocation() {
const locationChanged = adapter.name !== Settings.data.location.name;
const needsGeocoding = (adapter.latitude === "") || (adapter.longitude === "") || locationChanged;
if (!needsGeocoding) {
return;
}
if (isFetchingWeather) {
return;
}
isFetchingWeather = true;
if (locationChanged) {
root.coordinatesReady = false;
Logger.d("Location", "Location changed from", adapter.name, "to", Settings.data.location.name);
}
geocodeLocation(Settings.data.location.name, function (latitude, longitude, name, country) {
adapter.name = Settings.data.location.name;
adapter.latitude = latitude.toString();
adapter.longitude = longitude.toString();
root.stableLatitude = adapter.latitude;
root.stableLongitude = adapter.longitude;
root.stableName = `${name}, ${country}`;
root.coordinatesReady = true;
isFetchingWeather = false;
Logger.i("Location", `Geocoded ${Settings.data.location.name}: ${root.stableLatitude}, ${root.stableLongitude}`);
if (locationChanged) {
adapter.weatherLastFetch = 0;
updateWeatherData();
}
}, errorCallback);
}
// Fetch weather data if enabled and coordinates are available
function updateWeatherData() {
if (!Settings.data.location.weatherEnabled) {
return;
}
if (isFetchingWeather) {
return;
}
if (adapter.latitude === "" || adapter.longitude === "") {
Logger.w("Location", "Cannot fetch weather without coordinates");
return;
}
const needsWeatherUpdate = (adapter.weatherLastFetch === "") || (adapter.weather === null) || (Time.timestamp >= adapter.weatherLastFetch + weatherUpdateFrequency);
if (needsWeatherUpdate) {
isFetchingWeather = true;
fetchWeatherData(adapter.latitude, adapter.longitude, errorCallback);
}
}
// Query geocoding API to convert location name to coordinates
function geocodeLocation(locationName, callback, errorCallback) {
if (locationName === "") {
isFetchingWeather = false;
return;
}
Logger.d("Location", "Geocoding location name");
var geoUrl = "https://api.noctalia.dev/geocode?city=" + encodeURIComponent(locationName);
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
var geoData = JSON.parse(xhr.responseText);
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: ${xhr.status} ${xhr.responseText}`);
}
}
};
xhr.open("GET", geoUrl);
xhr.send();
}
// Fetch weather data from Open-Meteo API
function fetchWeatherData(latitude, longitude, errorCallback) {
Logger.d("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,is_day&daily=temperature_2m_max,temperature_2m_min,weathercode,sunset,sunrise&timezone=auto";
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
var weatherData = JSON.parse(xhr.responseText);
//console.log(JSON.stringify(weatherData))
// 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.d("Location", "Cached weather to disk - stable coordinates updated");
} catch (e) {
errorCallback("Location", "Failed to parse weather data");
}
} else {
errorCallback("Location", `Weather error: ${xhr.status} ${xhr.responseText}`);
}
}
};
xhr.open("GET", url);
xhr.send();
}
// Geolocate via IP address using the Noctalia API
function geolocate(callback, errorCallback) {
Logger.d("Location", "Geolocating via IP");
var url = "https://api.noctalia.dev/geolocate";
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
var data = JSON.parse(xhr.responseText);
if (data.lat != null) {
callback(data.lat, data.lng, data.city, data.country);
} else {
errorCallback("Location", "Geolocate: no coordinates returned");
}
} catch (e) {
errorCallback("Location", "Failed to parse geolocate data: " + e);
}
} else {
errorCallback("Location", `Geolocate error: ${xhr.status} ${xhr.responseText}`);
}
}
};
xhr.open("GET", url);
xhr.send();
}
// Geolocate via IP and apply the result as the current location
function geolocateAndApply() {
if (isFetchingWeather) {
Logger.w("Location", "Geolocate skipped, fetch already in progress");
return;
}
geolocate(function (lat, lng, city, country) {
Logger.i("Location", "Geolocated to", city + ",", country + ":", lat + "," + lng);
const locationChanged = adapter.name !== city;
Settings.data.location.name = city;
adapter.name = city;
adapter.latitude = lat.toString();
adapter.longitude = lng.toString();
root.stableLatitude = adapter.latitude;
root.stableLongitude = adapter.longitude;
root.stableName = `${city}, ${country}`;
root.coordinatesReady = true;
if (locationChanged) {
adapter.weatherLastFetch = 0;
adapter.weather = null;
}
if (Settings.data.location.weatherEnabled) {
updateWeatherData();
}
}, errorCallback);
}
// --------------------------------
function errorCallback(module, message) {
Logger.w(module, message);
isFetchingWeather = false;
}
// --------------------------------
function weatherSymbolFromCode(code) {
var isDay = data.weather ? data.weather.current_weather.is_day : true;
if (code === 0)
return isDay ? "weather-sun" : "weather-moon";
if (code === 1 || code === 2)
return isDay ? "weather-cloud-sun" : "weather-moon-stars";
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 >= 80 && code <= 82)
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 taliaWeatherImageFromCode(code) {
var isDay = data.weather ? data.weather.current_weather.is_day : true;
if (code >= 40 && code <= 49)
return Quickshell.shellDir + "/Assets/Talia/TaliaDazed.png";
if (code >= 95 && code <= 99)
return Quickshell.shellDir + "/Assets/Talia/TaliaFear.png";
var wet = (code >= 51 && code <= 67) || (code >= 80 && code <= 82) || (code >= 71 && code <= 77) || (code >= 85 && code <= 86);
if (wet)
return Quickshell.shellDir + "/Assets/Talia/TaliaSob.png";
if ((code === 0 || code === 1 || code === 2) && isDay === false)
return Quickshell.shellDir + "/Assets/Talia/TaliaVampire.png";
if ((code === 0 && isDay === true) || code === 1 || code === 2)
return Quickshell.shellDir + "/Assets/Talia/TaliaParty.png";
return Quickshell.shellDir + "/Assets/Talia/TaliaBlank.png";
}
// --------------------------------
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;
}
}
@@ -0,0 +1,290 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.UI
Singleton {
id: root
// Night Light properties - directly bound to settings
readonly property var params: Settings.data.nightLight
property var lastCommand: []
// Crash tracking for auto-restart
property int _crashCount: 0
property int _maxCrashes: 5
// Manual schedule tracking
property bool _manualNightPhase: false
// Kill any stale wlsunset processes on startup to prevent issues after shell restart
Component.onCompleted: {
killStaleProcess.running = true;
}
Process {
id: killStaleProcess
running: false
command: ["pkill", "-x", "wlsunset"]
onExited: function (code, status) {
if (code === 0) {
Logger.i("NightLight", "Killed stale wlsunset process from previous session");
}
// Now apply the settings after cleanup
root.apply();
}
}
Timer {
id: restartTimer
interval: 2000
repeat: false
onTriggered: {
if (root.params.enabled && !runner.running) {
Logger.w("NightLight", "Restarting after crash...");
if (root.isManualMode()) {
root.applyManualSchedule();
} else {
runner.running = true;
}
}
}
}
Timer {
id: manualScheduleTimer
repeat: false
onTriggered: {
Logger.i("NightLight", "Manual schedule: phase boundary reached");
root.applyManualSchedule();
}
}
function timeToMinutes(timeStr) {
var parts = timeStr.split(":").map(Number);
return parts[0] * 60 + parts[1];
}
function isManualMode() {
return !params.forced && !params.autoSchedule;
}
function isCurrentlyNight() {
var now = new Date();
var nowMin = now.getHours() * 60 + now.getMinutes();
var sunsetMin = timeToMinutes(params.manualSunset);
var sunriseMin = timeToMinutes(params.manualSunrise);
if (sunsetMin < sunriseMin) {
// Inverted: e.g. sunset=03:00, sunrise=07:00 → night is [03:00, 07:00)
return nowMin >= sunsetMin && nowMin < sunriseMin;
} else {
// Normal: e.g. sunset=18:00, sunrise=06:00 → night is [18:00, 06:00)
return nowMin >= sunsetMin || nowMin < sunriseMin;
}
}
function msUntilNextBoundary() {
var now = new Date();
var nowMin = now.getHours() * 60 + now.getMinutes();
var sunsetMin = timeToMinutes(params.manualSunset);
var sunriseMin = timeToMinutes(params.manualSunrise);
var targetMin = isCurrentlyNight() ? sunriseMin : sunsetMin;
var diffMin = targetMin - nowMin;
if (diffMin <= 0)
diffMin += 1440;
return diffMin * 60 * 1000 - now.getSeconds() * 1000 - now.getMilliseconds();
}
function applyManualSchedule() {
if (!params.enabled) {
manualScheduleTimer.stop();
runner.running = false;
return;
}
var night = isCurrentlyNight();
_manualNightPhase = night;
if (night) {
var cmd = ["wlsunset"];
cmd.push("-t", `${params.nightTemp}`, "-T", `${params.dayTemp}`);
cmd.push("-S", "23:59");
cmd.push("-s", "00:00");
cmd.push("-d", 1);
if (JSON.stringify(cmd) !== JSON.stringify(lastCommand) || !runner.running) {
lastCommand = cmd;
runner.command = cmd;
runner.running = false;
runner.running = true;
}
Logger.i("NightLight", "Manual schedule: night phase - wlsunset forced on");
} else {
lastCommand = [];
runner.running = false;
Logger.i("NightLight", "Manual schedule: day phase - wlsunset stopped");
}
var ms = msUntilNextBoundary();
manualScheduleTimer.interval = Math.max(ms, 1000);
manualScheduleTimer.restart();
Logger.i("NightLight", "Manual schedule: next boundary in " + Math.round(ms / 1000) + "s");
}
function apply(force = false) {
// If using LocationService, wait for it to be ready
if (!params.forced && params.autoSchedule && !LocationService.coordinatesReady) {
return;
}
// Manual mode: handle scheduling ourselves
if (isManualMode() && params.enabled) {
_crashCount = 0;
restartTimer.stop();
applyManualSchedule();
return;
}
// Not in manual mode - clean up manual timer
manualScheduleTimer.stop();
var command = buildCommand();
// Compare with previous command to avoid unnecessary restart
if (force || JSON.stringify(command) !== JSON.stringify(lastCommand)) {
lastCommand = command;
runner.command = command;
// Set running to false so it may restart below if still enabled
runner.running = false;
}
runner.running = params.enabled;
}
function buildCommand() {
var cmd = ["wlsunset"];
if (params.forced) {
// Force immediate full night temperature regardless of time
// Keep distinct day/night temps but set times so we're effectively always in "night"
cmd.push("-t", `${params.nightTemp}`, "-T", `${params.dayTemp}`);
// Night spans from sunset (00:00) to sunrise (23:59) covering almost the full day
cmd.push("-S", "23:59"); // sunrise very late
cmd.push("-s", "00:00"); // sunset at midnight
// Near-instant transition
cmd.push("-d", 1);
} else if (params.autoSchedule) {
cmd.push("-t", `${params.nightTemp}`, "-T", `${params.dayTemp}`);
cmd.push("-l", `${LocationService.stableLatitude}`, "-L", `${LocationService.stableLongitude}`);
cmd.push("-d", 60 * 15); // 15min progressive fade at sunset/sunrise
}
return cmd;
}
// Observe setting changes and location readiness
Connections {
target: Settings.data.nightLight
function onEnabledChanged() {
apply();
// Toast: night light toggled
const enabled = !!Settings.data.nightLight.enabled;
ToastService.showNotice(I18n.tr("common.night-light"), enabled ? I18n.tr("common.enabled") : I18n.tr("common.disabled"), enabled ? "nightlight-on" : "nightlight-off");
}
function onForcedChanged() {
apply();
if (Settings.data.nightLight.enabled) {
ToastService.showNotice(I18n.tr("common.night-light"), Settings.data.nightLight.forced ? I18n.tr("toast.night-light.forced") : I18n.tr("toast.night-light.normal"), Settings.data.nightLight.forced ? "nightlight-forced" : "nightlight-on");
}
}
function onNightTempChanged() {
apply();
}
function onDayTempChanged() {
apply();
}
function onManualSunriseChanged() {
apply();
}
function onManualSunsetChanged() {
apply();
}
function onAutoScheduleChanged() {
apply();
}
}
Connections {
target: LocationService
function onCoordinatesReadyChanged() {
if (LocationService.coordinatesReady) {
root.apply();
}
}
}
Timer {
id: resumeRetryTimer
interval: 2000
repeat: false
onTriggered: {
Logger.i("NightLight", "Resume retry - re-applying night light again");
root.apply(true);
}
}
Connections {
target: Time
function onResumed() {
Logger.i("NightLight", "System resumed - re-applying night light");
root.apply(true);
resumeRetryTimer.restart();
}
}
// Foreground process runner
Process {
id: runner
running: false
onStarted: {
Logger.i("NightLight", "Wlsunset started:", runner.command);
// Reset crash count on successful start
if (root._crashCount > 0) {
root._crashCount = 0;
}
}
onExited: function (code, status) {
if (root.params.enabled && root.isManualMode()) {
// Manual mode: only treat as crash if we're in the night phase
if (root._manualNightPhase) {
root._crashCount++;
if (root._crashCount <= root._maxCrashes) {
Logger.w("NightLight", "Wlsunset exited unexpectedly during manual night phase (code: " + code + "), restarting in 2s... (attempt " + root._crashCount + "/" + root._maxCrashes + ")");
restartTimer.start();
} else {
Logger.e("NightLight", "Wlsunset crashed too many times (" + root._maxCrashes + "), giving up");
}
} else {
Logger.i("NightLight", "Wlsunset exited (manual day phase):", code, status);
root._crashCount = 0;
}
} else if (root.params.enabled) {
// Non-manual mode: any exit while enabled is a crash
root._crashCount++;
if (root._crashCount <= root._maxCrashes) {
Logger.w("NightLight", "Wlsunset exited unexpectedly (code: " + code + "), restarting in 2s... (attempt " + root._crashCount + "/" + root._maxCrashes + ")");
restartTimer.start();
} else {
Logger.e("NightLight", "Wlsunset crashed too many times (" + root._maxCrashes + "), giving up");
}
} else {
Logger.i("NightLight", "Wlsunset exited (disabled):", code, status);
root._crashCount = 0;
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,356 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Services.Mpris
import qs.Commons
Singleton {
id: root
function formatTime(seconds) {
if (isNaN(seconds) || seconds < 0)
return "0:00";
var h = Math.floor(seconds / 3600);
var m = Math.floor((seconds % 3600) / 60);
var s = Math.floor(seconds % 60);
var pad = function (n) {
return (n < 10) ? ("0" + n) : n;
};
if (h > 0) {
return h + ":" + pad(m) + ":" + pad(s);
} else {
return m + ":" + pad(s);
}
}
property var currentPlayer: null
property string playerIdentity: currentPlayer ? (currentPlayer.identity || "") : ""
property real currentPosition: 0
property bool isSeeking: false
property int selectedPlayerIndex: 0
property bool isPlaying: currentPlayer ? (currentPlayer.playbackState === MprisPlaybackState.Playing || currentPlayer.isPlaying) : false
property string trackTitle: currentPlayer ? (currentPlayer.trackTitle !== undefined ? currentPlayer.trackTitle.replace(/(\r\n|\n|\r)/g, "") : "") : ""
property string trackArtist: currentPlayer ? (currentPlayer.trackArtist || "") : ""
property string trackAlbum: currentPlayer ? (currentPlayer.trackAlbum || "") : ""
property string trackArtUrl: currentPlayer ? (currentPlayer.trackArtUrl || "") : ""
property real trackLength: currentPlayer ? ((currentPlayer.length < infiniteTrackLength) ? currentPlayer.length : 0) : 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 string positionString: formatTime(currentPosition)
property string lengthString: formatTime(trackLength)
property real infiniteTrackLength: 922337203685
Component.onCompleted: {
updateCurrentPlayer();
}
function getAvailablePlayers() {
if (!Mpris.players || !Mpris.players.values) {
return [];
}
let allPlayers = Mpris.players.values;
let finalPlayers = [];
const genericBrowsers = ["firefox", "chromium", "chrome"];
const blacklist = (Settings.data.audio && Settings.data.audio.mprisBlacklist) ? Settings.data.audio.mprisBlacklist : [];
// Separate players into specific and generic lists
let specificPlayers = [];
let genericPlayers = [];
for (var i = 0; i < allPlayers.length; i++) {
if (!allPlayers[i])
continue;
const identity = String(allPlayers[i].identity || "").toLowerCase();
const dbusName = String(allPlayers[i].dbusName || "").toLowerCase();
const match = blacklist.find(b => {
const s = String(b || "").toLowerCase();
return s && (identity.includes(s) || dbusName.includes(s));
});
if (match)
continue;
if (genericBrowsers.some(b => identity.includes(b))) {
genericPlayers.push(allPlayers[i]);
} else {
specificPlayers.push(allPlayers[i]);
}
}
let matchedGenericIndices = {};
// For each specific player, try to find and pair it with a generic partner
for (var i = 0; i < specificPlayers.length; i++) {
let specificPlayer = specificPlayers[i];
let title1 = String(specificPlayer.trackTitle || "").trim();
let wasMatched = false;
if (title1) {
for (var j = 0; j < genericPlayers.length; j++) {
if (matchedGenericIndices[j])
continue;
let genericPlayer = genericPlayers[j];
let title2 = String(genericPlayer.trackTitle || "").trim();
if (title2 && (title1.includes(title2) || title2.includes(title1))) {
let dataPlayer = genericPlayer;
let identityPlayer = specificPlayer;
let scoreSpecific = (specificPlayer.trackArtUrl ? 1 : 0);
let scoreGeneric = (genericPlayer.trackArtUrl ? 1 : 0);
if (scoreSpecific > scoreGeneric) {
dataPlayer = specificPlayer;
}
let virtualPlayer = {
"identity": identityPlayer.identity,
"desktopEntry": identityPlayer.desktopEntry,
"trackTitle": dataPlayer.trackTitle,
"trackArtist": dataPlayer.trackArtist,
"trackAlbum": dataPlayer.trackAlbum,
"trackArtUrl": dataPlayer.trackArtUrl,
"length": dataPlayer.length || 0,
"position": dataPlayer.position || 0,
"playbackState": dataPlayer.playbackState,
"isPlaying": dataPlayer.isPlaying || false,
"canPlay": dataPlayer.canPlay || false,
"canPause": dataPlayer.canPause || false,
"canGoNext": dataPlayer.canGoNext || false,
"canGoPrevious": dataPlayer.canGoPrevious || false,
"canSeek": dataPlayer.canSeek || false,
"canControl": dataPlayer.canControl || false,
"_stateSource": dataPlayer,
"_controlTarget": identityPlayer
};
finalPlayers.push(virtualPlayer);
matchedGenericIndices[j] = true;
wasMatched = true;
break;
}
}
}
if (!wasMatched) {
finalPlayers.push(specificPlayer);
}
}
// Add any generic players that were not matched
for (var i = 0; i < genericPlayers.length; i++) {
if (!matchedGenericIndices[i]) {
finalPlayers.push(genericPlayers[i]);
}
}
// Filter for controllable players
let controllablePlayers = [];
for (var i = 0; i < finalPlayers.length; i++) {
let player = finalPlayers[i];
if (player && player.canPlay) {
controllablePlayers.push(player);
}
}
return controllablePlayers;
}
function findActivePlayer() {
let availablePlayers = getAvailablePlayers();
if (availablePlayers.length === 0) {
//Logger.i("Media", "No active player found")
return null;
}
// Prioritize the actively playing player ---
for (var i = 0; i < availablePlayers.length; i++) {
if (availablePlayers[i] && availablePlayers[i].playbackState === MprisPlaybackState.Playing) {
Logger.d("Media", "Found actively playing player: " + availablePlayers[i].identity);
selectedPlayerIndex = i;
return availablePlayers[i];
}
}
// fallback if nothing is playing)
const preferred = (Settings.data.audio.preferredPlayer || "");
if (preferred !== "") {
for (var i = 0; i < availablePlayers.length; i++) {
const p = availablePlayers[i];
const identity = String(p.identity || "").toLowerCase();
const pref = preferred.toLowerCase();
if (identity.includes(pref)) {
selectedPlayerIndex = i;
return p;
}
}
}
if (selectedPlayerIndex < availablePlayers.length) {
return availablePlayers[selectedPlayerIndex];
} else {
selectedPlayerIndex = 0;
return availablePlayers[0];
}
}
property bool autoSwitchingPaused: false
function switchToPlayer(index) {
let availablePlayers = getAvailablePlayers();
if (index >= 0 && index < availablePlayers.length) {
let newPlayer = availablePlayers[index];
if (newPlayer !== currentPlayer) {
currentPlayer = newPlayer;
selectedPlayerIndex = index;
currentPosition = currentPlayer ? currentPlayer.position : 0;
Logger.d("Media", "Manually switched to player " + currentPlayer.identity);
}
}
}
// Switch to the most recently active player
function updateCurrentPlayer() {
let newPlayer = findActivePlayer();
if (newPlayer !== currentPlayer) {
currentPlayer = newPlayer;
currentPosition = currentPlayer ? currentPlayer.position : 0;
Logger.d("Media", "Switching player");
}
}
function playPause() {
if (currentPlayer) {
let stateSource = currentPlayer._stateSource || currentPlayer;
let controlTarget = currentPlayer._controlTarget || currentPlayer;
if (stateSource.playbackState === MprisPlaybackState.Playing) {
controlTarget.pause();
} else {
controlTarget.play();
}
}
}
function play() {
let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null;
if (target && target.canPlay) {
target.play();
}
}
function stop() {
let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null;
if (target) {
target.stop();
}
}
function pause() {
let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null;
if (target && target.canPause) {
target.pause();
}
}
function next() {
let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null;
if (target && target.canGoNext) {
target.next();
}
}
function previous() {
let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null;
if (target && target.canGoPrevious) {
target.previous();
}
}
function seek(position) {
let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null;
if (target && target.canSeek) {
target.position = position;
currentPosition = position;
}
}
function seekRelative(offset) {
let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null;
if (target && target.canSeek && target.length > 0) {
let seekPosition = target.position + offset;
target.position = seekPosition;
currentPosition = seekPosition;
}
}
// Seek to position based on ratio (0.0 to 1.0)
function seekByRatio(ratio) {
let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null;
if (target && target.canSeek && target.length > 0) {
let seekPosition = ratio * target.length;
target.position = seekPosition;
currentPosition = seekPosition;
}
}
// Update progress bar every second while playing
Timer {
id: positionTimer
interval: 1000
running: currentPlayer && !root.isSeeking && currentPlayer.isPlaying && currentPlayer.length > 0 && currentPlayer.playbackState === MprisPlaybackState.Playing
repeat: true
onTriggered: {
if (currentPlayer && !root.isSeeking && currentPlayer.isPlaying && currentPlayer.playbackState === MprisPlaybackState.Playing) {
currentPosition = currentPlayer.position;
} else {
running = false;
}
}
}
// Avoid overwriting currentPosition while seeking due to backend position changes
Connections {
target: currentPlayer
function onPositionChanged() {
if (!root.isSeeking && currentPlayer) {
currentPosition = currentPlayer.position;
}
}
function onPlaybackStateChanged() {
if (!root.isSeeking && currentPlayer) {
currentPosition = currentPlayer.position;
}
}
}
// Reset position when switching to inactive player
onCurrentPlayerChanged: {
if (!currentPlayer || !currentPlayer.isPlaying || currentPlayer.playbackState !== MprisPlaybackState.Playing) {
currentPosition = 0;
}
}
Timer {
id: playerStateMonitor
interval: 2000 // Check every 2 seconds
repeat: true
running: true
onTriggered: {
//Logger.d("MediaService", "playerStateMonitor triggered. autoSwitchingPaused: " + root.autoSwitchingPaused)
if (autoSwitchingPaused)
return;
// Only update if we don't have a playing player or if current player is paused
if (!currentPlayer || !currentPlayer.isPlaying || currentPlayer.playbackState !== MprisPlaybackState.Playing) {
updateCurrentPlayer();
}
}
}
// Update current player when available players change
Connections {
target: Mpris.players
function onValuesChanged() {
Logger.d("Media", "Players changed");
updateCurrentPlayer();
}
}
}
@@ -0,0 +1,81 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import qs.Commons
import qs.Services.UI
Singleton {
id: root
// TODO Remove in may 2026
Component.onCompleted: {
_setBandsCount();
}
// Register a component that needs audio data, call this when a visualizer becomes active.
// Pass a unique identifier (e.g., "lockscreen", "controlcenter:screen1", "plugin:fancy-audiovisualizer")
function registerComponent(componentId) {
root._registeredComponents[componentId] = true;
root._registeredComponents = Object.assign({}, root._registeredComponents);
Logger.d("Spectrum", "Component registered:", componentId, "- total:", root._registeredCount);
}
// Unregister a component when it no longer needs audio data.
function unregisterComponent(componentId) {
delete root._registeredComponents[componentId];
root._registeredComponents = Object.assign({}, root._registeredComponents);
Logger.d("Spectrum", "Component unregistered:", componentId, "- total:", root._registeredCount);
}
// Check if a component is registered
function isRegistered(componentId) {
return root._registeredComponents[componentId] === true;
}
// Component registration - any component needing audio data registers here
property var _registeredComponents: ({})
readonly property int _registeredCount: Object.keys(_registeredComponents).length
property bool _shouldRun: _registeredCount > 0
property var values: []
property bool isIdle: true
PwAudioSpectrum {
id: spectrum
node: Pipewire.defaultAudioSink
enabled: root._shouldRun
// TODO Uncomment this in may 2026
// bandCount: Settings.data.audio.spectrumMirrored ? 32 : 64
frameRate: Settings.data.audio.spectrumFrameRate
lowerCutoff: 50
upperCutoff: 12000
noiseReduction: 0.77
smoothing: true
onValuesChanged: {
root.values = spectrum.values;
}
onIdleChanged: {
root.isIdle = spectrum.idle;
}
}
// TODO Remove in may 2026 - temporary until noctalia-qs is fully propagated
Connections {
target: Settings.data.audio
function onSpectrumMirroredChanged() {
_setBandsCount();
}
}
function _setBandsCount() {
const bandCount = Settings.data.audio.spectrumMirrored ? 32 : 64;
if (spectrum.bandCount !== undefined) {
spectrum.bandCount = bandCount;
} else if (spectrum.barCount !== undefined) {
spectrum.barCount = bandCount;
}
}
}
@@ -0,0 +1,72 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "../../Helpers/BluetoothUtils.js" as BluetoothUtils
QtObject {
id: root
// Controls
property bool enabled: false
property int intervalMs: 10000
property var connectedDevices: []
// Output cache and version for bindings
property var cache: ({}) // addr -> percent (0..100)
property int version: 0
// Internal rotation state
property int _index: 0
property string _currentAddr: ""
// Single process reused for RSSI queries
property Process rssiProcess: Process {
id: proc
running: false
stdout: StdioCollector {
id: out
}
onExited: function (exitCode, exitStatus) {
try {
var text = out.text || "";
var dbm = BluetoothUtils.parseRssiOutput(text);
if (root._currentAddr !== "" && dbm !== null) {
var pct = BluetoothUtils.dbmToPercent(dbm);
if (pct !== null) {
root.cache[root._currentAddr] = pct;
root.version++;
}
}
} catch (e) {} finally {
root._currentAddr = "";
}
}
}
// Periodic RSSI polling timer
property Timer rssiTimer: Timer {
interval: root.intervalMs
repeat: true
running: root.enabled
onTriggered: {
var list = root.connectedDevices || [];
if (!list || list.length === 0)
return;
if (root._index >= list.length)
root._index = 0;
var dev = list[root._index++];
if (!dev)
return;
var addr = BluetoothUtils.macFromDevice(dev);
if (!addr || addr.length < 7)
return;
if (proc.running)
return; // avoid overlap
root._currentAddr = addr;
proc.command = ["sh", "-c", `bluetoothctl info "${addr}"`];
try {
proc.running = true;
} catch (e) {}
}
}
}
@@ -0,0 +1,542 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Bluetooth
import Quickshell.Io
import "../../Helpers/BluetoothUtils.js" as BluetoothUtils
import qs.Commons
import qs.Services.System
import qs.Services.UI
Singleton {
id: root
readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter
// Power/availability state
readonly property bool bluetoothAvailable: !!adapter
readonly property bool enabled: adapter?.enabled ?? false
readonly property bool blocked: adapter?.state === BluetoothAdapter.Blocked
// Exposed scanning flag for UI button state; reflects adapter discovery when available
readonly property bool scanningActive: adapter?.discovering ?? false
// Adapter discoverability (advertising) flag
readonly property bool discoverable: adapter?.discoverable ?? false
readonly property var devices: adapter ? adapter.devices : null
readonly property var connectedDevices: {
if (!adapter || !adapter.devices) {
return [];
}
return adapter.devices.values.filter(dev => dev && dev.connected);
}
// Experimental: besteffort RSSI polling for connected devices (without root)
// Enabled in debug mode or via user setting in Settings > Network
property bool rssiPollingEnabled: Settings?.data?.network?.bluetoothRssiPollingEnabled || Settings?.isDebug || false
// Interval can be configured from Settings; defaults to 60s
property int rssiPollIntervalMs: Settings?.data?.network?.bluetoothRssiPollIntervalMs || 60000
// RSSI helper subcomponent
property BluetoothRssi rssi: BluetoothRssi {
enabled: root.enabled && root.rssiPollingEnabled
intervalMs: root.rssiPollIntervalMs
connectedDevices: root.connectedDevices
}
// Tunables for CLI pairing/connect flow
property int pairWaitSeconds: 45
property int connectAttempts: 5
property int connectRetryIntervalMs: 2000
// Interaction state
property bool pinRequired: false
// Internal variables
property bool _discoveryWasRunning: false
property bool _ctlInit: false
property var _autoConnectQueue: []
// Persistent cache for per-device auto-connect toggle
property string cacheFile: Settings.cacheDir + "bluetooth_devices.json"
FileView {
id: cacheFileView
path: root.cacheFile
printErrors: false
JsonAdapter {
id: cacheAdapter
property var autoConnectSettings: ({})
}
}
// Handle system wakeup to force-poll and ensure state is up-to-date
Connections {
target: Time
function onResumed() {
Logger.i("Bluetooth", "System resumed - forcing state poll");
ctlPollTimer.restart();
}
}
// Track adapter state changes
Connections {
target: adapter
function onStateChanged() {
if (!adapter || adapter.state === BluetoothAdapter.Enabling || adapter.state === BluetoothAdapter.Disabling) {
return;
}
checkAirplaneMode();
}
function onEnabledChanged() {
if (adapter && adapter.enabled && Settings.data.network.bluetoothAutoConnect) {
autoConnectTimer.restart();
}
}
}
Connections {
target: Settings.data.network
function onBluetoothAutoConnectChanged() {
if (Settings.data.network.bluetoothAutoConnect && adapter && adapter.enabled) {
autoConnectTimer.restart();
} else {
autoConnectTimer.stop();
}
}
}
Component.onCompleted: {
Logger.i("Bluetooth", "Service started");
autoConnectTimer.restart();
}
Timer {
id: autoConnectTimer
interval: 1500
repeat: false
onTriggered: attemptAutoConnect()
}
Timer {
id: autoConnectStepTimer
interval: 500
repeat: false
onTriggered: {
var device = root._autoConnectQueue.shift();
if (device && device.paired && !device.connected && !device.blocked) {
Logger.i("Bluetooth", "Auto-connecting to:", device.name || device.deviceName);
connectDeviceWithTrust(device);
}
if (root._autoConnectQueue.length > 0) {
autoConnectStepTimer.restart();
}
}
}
Timer {
id: ctlPollTimer
interval: 250
running: false
onTriggered: {
if (!adapter || !ProgramCheckerService.bluetoothctlAvailable) {
return;
}
ctlPollProcess.running = true;
}
}
// Adapter power (enable/disable) via bluetoothctl
function setBluetoothEnabled(state) {
if (!adapter) {
Logger.d("Bluetooth", "Enable/Disable skipped: no adapter");
return;
}
try {
adapter.enabled = state;
Logger.i("Bluetooth", "SetBluetoothEnabled", state);
} catch (e) {
Logger.w("Bluetooth", "Enable/Disable failed", e);
ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.state-change-failed"));
}
}
// Check if airplane mode has been toggled
function checkAirplaneMode() {
var isAirplaneModeActive = !NetworkService.wifiEnabled && adapter.state === BluetoothAdapter.Blocked;
if (isAirplaneModeActive && !NetworkService.airplaneModeEnabled) {
NetworkService.airplaneModeToggled = true;
NetworkService.airplaneModeEnabled = true;
ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr("common.enabled"), "plane");
Logger.i("AirplaneMode", "Enabled");
} else if (!isAirplaneModeActive && NetworkService.airplaneModeEnabled) {
NetworkService.airplaneModeToggled = true;
NetworkService.airplaneModeEnabled = false;
ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr("common.disabled"), "plane-off");
Logger.i("AirplaneMode", "Disabled");
} else if (adapter.enabled) {
ToastService.showNotice(I18n.tr("common.bluetooth"), I18n.tr("common.enabled"), "bluetooth");
Logger.d("Bluetooth", "Adapter enabled");
} else {
ToastService.showNotice(I18n.tr("common.bluetooth"), I18n.tr("common.disabled"), "bluetooth-off");
Logger.d("Bluetooth", "Adapter disabled");
}
}
// Unify discovery controls
function setScanActive(active) {
if (!adapter) {
Logger.d("Bluetooth", "Scan request ignored: adapter unavailable");
return;
}
try {
if (active || adapter.discovering) { // Only attempt to set if activating, or if deactivating and currently currently discovering
adapter.discovering = active;
}
} catch (e) {
Logger.e("Bluetooth", "setScanActive failed", e);
}
}
// Toggle adapter discoverability (advertising visibility) via bluetoothctl
function setDiscoverable(state) {
if (!adapter) {
Logger.d("Bluetooth", "Discoverable change skipped: no adapter");
return;
}
try {
adapter.discoverable = state;
Logger.i("Bluetooth", "Discoverable state set to:", state);
} catch (e) {
Logger.w("Bluetooth", "Failed to change discoverable state", e);
ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.discoverable-change-failed"));
}
}
function sortDevices(devices) {
return devices.sort(function (a, b) {
var aName = a.name || a.deviceName || "";
var bName = b.name || b.deviceName || "";
var aHasRealName = aName.indexOf(" ") !== -1 && aName.length > 3;
var bHasRealName = bName.indexOf(" ") !== -1 && bName.length > 3;
if (aHasRealName && !bHasRealName) {
return -1;
}
if (!aHasRealName && bHasRealName) {
return 1;
}
var aSignal = (a.signalStrength !== undefined && a.signalStrength > 0) ? a.signalStrength : 0;
var bSignal = (b.signalStrength !== undefined && b.signalStrength > 0) ? b.signalStrength : 0;
return bSignal - aSignal;
});
}
function getDeviceIcon(device) {
if (!device) {
return "bt-device-generic";
}
return BluetoothUtils.deviceIcon(device.name || device.deviceName, device.icon);
}
function canConnect(device) {
if (!device) {
return false;
}
return !device.connected && (device.paired || device.trusted) && !device.pairing && !device.blocked;
}
function canDisconnect(device) {
if (!device) {
return false;
}
return device.connected && !device.pairing && !device.blocked;
}
// Textual signal quality (translated)
function getSignalStrength(device) {
var p = getSignalPercent(device);
if (p === null) {
return I18n.tr("bluetooth.panel.signal-text-unknown");
}
if (p >= 80) {
return I18n.tr("bluetooth.panel.signal-text-excellent");
}
if (p >= 60) {
return I18n.tr("bluetooth.panel.signal-text-good");
}
if (p >= 40) {
return I18n.tr("bluetooth.panel.signal-text-fair");
}
if (p >= 20) {
return I18n.tr("bluetooth.panel.signal-text-poor");
}
return I18n.tr("bluetooth.panel.signal-text-very-poor");
}
// Numeric helpers for UI rendering
function getSignalPercent(device) {
// Establish binding dependency so UI updates when RSSI cache changes
var _v = rssi.version;
return BluetoothUtils.signalPercent(device, rssi.cache, _v);
}
function getBatteryPercent(device) {
return BluetoothUtils.batteryPercent(device);
}
function getSignalIcon(device) {
var p = getSignalPercent(device);
return BluetoothUtils.signalIcon(p);
}
function isDeviceBusy(device) {
if (!device) {
return false;
}
return device.pairing || device.state === BluetoothDevice.Disconnecting || device.state === BluetoothDevice.Connecting;
}
// Return a stable unique key for a device (prefer MAC address)
function deviceKey(device) {
return BluetoothUtils.deviceKey(device);
}
// Deduplicate a list of devices using the stable key
function dedupeDevices(devList) {
return BluetoothUtils.dedupeDevices(devList);
}
// Separate capability helpers
function canPair(device) {
if (!device) {
return false;
}
return !device.connected && !device.paired && !device.trusted && !device.pairing && !device.blocked;
}
// Pairing and unpairing helpers
function pairDevice(device) {
if (!device) {
return;
}
ToastService.showNotice(I18n.tr("common.bluetooth"), I18n.tr("common.pairing"), "bluetooth");
try {
pairWithBluetoothctl(device);
} catch (e) {
Logger.w("Bluetooth", "pairDevice failed", e);
ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.pair-failed"));
}
}
function submitPin(pin) {
if (pairingProcess.running) {
pairingProcess.write(pin + "\n");
root.pinRequired = false;
}
}
function cancelPairing() {
if (pairingProcess.running) {
pairingProcess.running = false;
}
root.pinRequired = false;
}
// Pair using bluetoothctl which registers its own BlueZ agent internally.
function pairWithBluetoothctl(device) {
if (!device) {
return;
}
var addr = BluetoothUtils.macFromDevice(device);
if (!addr || addr.length < 7) {
Logger.w("Bluetooth", "pairWithBluetoothctl: no valid address for device");
return;
}
Logger.i("Bluetooth", "pairWithBluetoothctl", addr);
if (pairingProcess.running) {
pairingProcess.running = false;
}
root.pinRequired = false;
const pairWait = Math.max(5, Number(root.pairWaitSeconds) | 0);
const attempts = Math.max(1, Number(root.connectAttempts) | 0);
const intervalMs = Math.max(500, Number(root.connectRetryIntervalMs) | 0);
const intervalSec = Math.max(1, Math.round(intervalMs / 1000));
// Temporarily pause discovery during pair/connect to reduce HCI churn
root._discoveryWasRunning = root.scanningActive;
if (root.scanningActive) {
root.setScanActive(false);
}
const scriptPath = Quickshell.shellDir + "/Scripts/python/src/network/bluetooth-pair.py";
pairingProcess.command = ["python3", scriptPath, String(addr), String(pairWait), String(attempts), String(intervalSec)];
pairingProcess.running = true;
}
// Helper to run bluetoothctl and scripts with consistent error logging
function btExec(args) {
try {
Quickshell.execDetached(args);
} catch (e) {
Logger.w("Bluetooth", "btExec failed", e);
}
}
// Status key for a device (untranslated)
function getStatusKey(device) {
if (!device) {
return "";
}
try {
if (device.pairing)
return "pairing";
if (device.blocked)
return "blocked";
if (device.state === BluetoothDevice.Connecting)
return "connecting";
if (device.state === BluetoothDevice.Disconnecting)
return "disconnecting";
} catch (_) {}
return "";
}
function unpairDevice(device) {
forgetDevice(device);
}
function getDeviceAutoConnect(device) {
if (!device || !device.address || !cacheAdapter.autoConnectSettings) {
return false;
}
const mac = device.address;
const settings = cacheAdapter.autoConnectSettings[mac];
return settings ? !!settings.autoConnect : false;
}
function setDeviceAutoConnect(device, enabled) {
if (!device || !device.address) {
return;
}
const mac = device.address;
let settings = cacheAdapter.autoConnectSettings || ({});
if (enabled) {
settings[mac] = {
autoConnect: true,
deviceName: device.name || device.deviceName || ""
};
} else {
delete settings[mac];
}
cacheAdapter.autoConnectSettings = settings;
cacheFileView.writeAdapter();
}
function attemptAutoConnect() {
if (NetworkService.airplaneModeEnabled || !adapter || !adapter.enabled || !Settings.data.network.bluetoothAutoConnect) {
return;
}
_autoConnectQueue = adapter.devices.values.filter(dev => dev && dev.paired && !dev.connected && !dev.blocked && getDeviceAutoConnect(dev) === true);
if (root._autoConnectQueue.length > 0) {
autoConnectStepTimer.restart();
}
}
function connectDeviceWithTrust(device) {
if (!device) {
return;
}
try {
device.trusted = true;
device.connect();
} catch (e) {
Logger.w("Bluetooth", "connectDeviceWithTrust failed", e);
ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.connect-failed"));
}
}
function disconnectDevice(device) {
if (!device) {
return;
}
try {
device.disconnect();
} catch (e) {
Logger.w("Bluetooth", "disconnectDevice failed", e);
ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.disconnect-failed"));
}
}
function forgetDevice(device) {
if (!device) {
return;
}
try {
device.trusted = false;
device.forget();
} catch (e) {
Logger.w("Bluetooth", "forgetDevice failed", e);
ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.forget-failed"));
}
}
// Poll Bluetooth power state with bluetoothctl to handle a Quickshell bug on resume after suspend
Process {
id: ctlPollProcess
command: ["bluetoothctl", "show"]
running: false
stdout: StdioCollector {
onStreamFinished: {
var powered = false;
var mp = text.match(/\bPowered:\s*(yes|no)\b/i);
if (mp) {
powered = mp[1].toLowerCase() === 'yes';
}
if (adapter.enabled !== powered) {
adapter.enabled = powered;
}
}
}
stderr: StdioCollector {
onStreamFinished: {
if (text.trim()) {
Logger.d("Bluetooth", "Failed to parse bluetoothctl show output" + text);
}
}
}
}
// Interactive pairing process
Process {
id: pairingProcess
stdout: SplitParser {
onRead: data => {
Logger.d("Bluetooth", data);
if (data.indexOf("PIN_REQUIRED") !== -1) {
root.pinRequired = true;
Logger.i("Bluetooth", "PIN required for pairing");
}
}
}
onExited: {
root.pinRequired = false;
Logger.i("Bluetooth", "Pairing process exited.");
// Restore discovery if we paused it
if (root._discoveryWasRunning) {
root.setScanActive(true);
}
root._discoveryWasRunning = false;
}
environment: ({
"LC_ALL": "C"
})
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,287 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.UI
Singleton {
id: root
property var connections: ({})
property bool refreshing: false
property bool connecting: false
property bool disconnecting: false
property string connectingUuid: ""
property string disconnectingUuid: ""
property string lastError: ""
property bool refreshPending: false
readonly property var activeConnections: {
const result = [];
const map = connections;
for (const key in map) {
const conn = map[key];
if (conn && conn.active) {
result.push(conn);
}
}
return result;
}
readonly property var inactiveConnections: {
const result = [];
const map = connections;
for (const key in map) {
const conn = map[key];
if (conn && !conn.active) {
result.push(conn);
}
}
return result;
}
readonly property bool hasActiveConnection: activeConnections.length > 0
Timer {
id: refreshTimer
interval: 5000
running: true
repeat: true
onTriggered: refresh()
}
Timer {
id: delayedRefreshTimer
interval: 1000
repeat: false
onTriggered: refresh()
}
Component.onCompleted: {
Logger.i("VPN", "Service started");
refresh();
}
function refresh() {
if (refreshing) {
refreshPending = true;
return;
}
refreshing = true;
lastError = "";
refreshProcess.running = true;
}
function connect(uuid) {
if (connecting || !uuid) {
return;
}
const conn = connections[uuid];
if (!conn) {
return;
}
connecting = true;
connectingUuid = uuid;
lastError = "";
connectProcess.uuid = uuid;
connectProcess.name = conn.name;
connectProcess.running = true;
}
function disconnect(uuid) {
if (disconnecting || !uuid) {
return;
}
const conn = connections[uuid];
if (!conn) {
return;
}
disconnecting = true;
disconnectingUuid = uuid;
lastError = "";
disconnectProcess.uuid = uuid;
disconnectProcess.name = conn.name;
disconnectProcess.running = true;
}
function toggle(uuid) {
const conn = connections[uuid];
if (!conn) {
return;
}
if (conn.active) {
disconnect(uuid);
} else {
connect(uuid);
}
}
function setConnection(uuid, data) {
if (!uuid) {
return;
}
const map = Object.assign({}, connections);
if (map[uuid]) {
map[uuid] = Object.assign({}, map[uuid], data);
connections = map;
}
}
function scheduleRefresh(interval) {
delayedRefreshTimer.interval = interval;
delayedRefreshTimer.restart();
}
Process {
id: refreshProcess
running: false
command: ["nmcli", "-t", "-f", "NAME,UUID,TYPE,DEVICE", "connection", "show"]
stdout: StdioCollector {
onStreamFinished: {
const lines = text.split("\n");
const map = {};
for (let i = 0; i < lines.length; ++i) {
const line = lines[i].trim();
if (!line) {
continue;
}
const lastColonIdx = line.lastIndexOf(":");
if (lastColonIdx === -1) {
continue;
}
const device = line.substring(lastColonIdx + 1);
const remaining = line.substring(0, lastColonIdx);
const secondLastColonIdx = remaining.lastIndexOf(":");
if (secondLastColonIdx === -1) {
continue;
}
const type = remaining.substring(secondLastColonIdx + 1);
if (type !== "vpn" && type !== "wireguard") {
continue;
}
const remaining2 = remaining.substring(0, secondLastColonIdx);
const thirdLastColonIdx = remaining2.lastIndexOf(":");
if (thirdLastColonIdx === -1) {
continue;
}
const uuid = remaining2.substring(thirdLastColonIdx + 1);
const name = remaining2.substring(0, thirdLastColonIdx);
if (!uuid || !name) {
continue;
}
const active = device && device !== "--";
map[uuid] = {
"uuid": uuid,
"name": name,
"device": device,
"active": active
};
}
connections = map;
const pending = refreshPending;
refreshing = false;
refreshPending = false;
if (pending) {
scheduleRefresh(200);
}
}
}
stderr: StdioCollector {
onStreamFinished: {
const pending = refreshPending;
refreshing = false;
refreshPending = false;
if (text.trim()) {
lastError = text.split("\n")[0].trim();
Logger.w("VPN", "Refresh error: " + text);
}
if (pending) {
scheduleRefresh(2000);
}
}
}
}
Process {
id: connectProcess
property string uuid: ""
property string name: ""
running: false
command: ["nmcli", "connection", "up", "uuid", uuid]
stdout: StdioCollector {
onStreamFinished: {
const output = text.trim();
if (!output || (!output.includes("successfully activated") && !output.includes("Connection successfully"))) {
return;
}
setConnection(connectProcess.uuid, {
"active": true
});
connecting = false;
connectingUuid = "";
lastError = "";
Logger.i("VPN", "Connected to " + connectProcess.name);
ToastService.showNotice(connectProcess.name, I18n.tr("toast.vpn.connected", {
"name": connectProcess.name
}), "shield-lock");
scheduleRefresh(1000);
}
}
stderr: StdioCollector {
onStreamFinished: {
const trimmed = text.trim();
if (trimmed) {
lastError = trimmed.split("\n")[0].trim();
Logger.w("VPN", "Connect error: " + trimmed);
ToastService.showWarning(connectProcess.name, lastError);
}
connecting = false;
connectingUuid = "";
}
}
}
Process {
id: disconnectProcess
property string uuid: ""
property string name: ""
running: false
command: ["nmcli", "connection", "down", "uuid", uuid]
stdout: StdioCollector {
onStreamFinished: {
Logger.i("VPN", "Disconnected from " + disconnectProcess.name);
setConnection(disconnectProcess.uuid, {
"active": false,
"device": ""
});
disconnecting = false;
disconnectingUuid = "";
lastError = "";
ToastService.showNotice(disconnectProcess.name, I18n.tr("toast.vpn.disconnected", {
"name": disconnectProcess.name
}), "shield-off");
scheduleRefresh(1000);
}
}
stderr: StdioCollector {
onStreamFinished: {
const trimmed = text.trim();
if (trimmed) {
lastError = trimmed.split("\n")[0].trim();
Logger.w("VPN", "Disconnect error: " + trimmed);
ToastService.showWarning(disconnectProcess.name, lastError);
}
disconnecting = false;
disconnectingUuid = "";
}
}
}
}
@@ -0,0 +1,336 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.UI
// GitHub API logic for contributors
Singleton {
id: root
property string githubDataFile: Quickshell.env("NOCTALIA_GITHUB_FILE") || (Settings.cacheDir + "github.json")
property int githubUpdateFrequency: 60 * 60 // 1 hour expressed in seconds
property bool isFetchingData: false
readonly property alias data: adapter // Used to access via GitHubService.data.xxx.yyy
// Public properties for easy access
property string latestVersion: I18n.tr("common.unknown")
property string latestQSVersion: I18n.tr("common.unknown")
property var contributors: []
// Avatar caching properties (simplified - uses ImageCacheService)
property var cachedAvatars: ({}) // username → file:// path
property bool avatarsCached: false // Track if we've already processed avatars
property bool isInitialized: false
FileView {
id: githubDataFileView
path: githubDataFile
printErrors: false
watchChanges: false // Disable to prevent reload on our own writes
Component.onCompleted: {
// Data loading handled by FileView onLoaded
}
onLoaded: {
if (!root.isInitialized) {
root.isInitialized = true;
loadFromCache();
}
}
onLoadFailed: function (error) {
if (error.toString().includes("No such file") || error === 2) {
// No cache file exists, fetch fresh data
root.isInitialized = true;
fetchFromGitHub();
}
}
JsonAdapter {
id: adapter
property string version: I18n.tr("common.unknown")
property string qsVersion: I18n.tr("common.unknown")
property var contributors: []
property real timestamp: 0
}
}
// --------------------------------
function init() {
Logger.i("GitHub", "Service started");
// FileView will handle loading automatically via onLoaded
}
// --------------------------------
function loadFromCache() {
const now = Time.timestamp;
var needsRefetch = false;
Logger.i("GitHub", "Checking cache - timestamp:", data.timestamp, "now:", now, "age:", data.timestamp ? Math.round((now - data.timestamp) / 60) : "N/A", "minutes");
if (!data.timestamp || (now >= data.timestamp + githubUpdateFrequency)) {
needsRefetch = true;
Logger.i("GitHub", "Cache expired or missing, scheduling fetch (update frequency:", Math.round(githubUpdateFrequency / 60), "minutes)");
} else {
Logger.i("GitHub", "Cache is fresh, using cached data (age:", Math.round((now - data.timestamp) / 60) + " minutes)");
}
if (data.version) {
root.latestVersion = data.version;
}
if (data.qsVersion) {
root.latestQSVersion = data.qsVersion;
}
if (data.contributors && data.contributors.length > 0) {
root.contributors = data.contributors;
Logger.d("GitHub", "Loaded", data.contributors.length, "contributors from cache");
}
if (needsRefetch) {
fetchFromGitHub();
}
}
// --------------------------------
function fetchFromGitHub() {
if (isFetchingData) {
Logger.d("GitHub", "GitHub data is still fetching");
return;
}
isFetchingData = true;
versionProcess.running = true;
qsVersionProcess.running = true;
contributorsProcess.running = true;
}
// --------------------------------
function saveData() {
data.timestamp = Time.timestamp;
Logger.d("GitHub", "Saving data to cache file:", githubDataFile, "with timestamp:", data.timestamp);
Logger.d("GitHub", "Data to save - version:", data.version, "qsVersion:", data.qsVersion, "contributors:", data.contributors.length);
// Ensure cache directory exists
Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir]);
try {
// Write immediately instead of Qt.callLater to ensure it completes
githubDataFileView.writeAdapter();
Logger.d("GitHub", "Cache file written successfully");
} catch (error) {
Logger.e("GitHub", "Failed to write cache file:", error);
}
}
// --------------------------------
function checkAndSaveData() {
// Only save when all processes are finished
if (!versionProcess.running && !qsVersionProcess.running && !contributorsProcess.running) {
root.isFetchingData = false;
// Check results
var anySucceeded = versionProcess.fetchSucceeded || qsVersionProcess.fetchSucceeded || contributorsProcess.fetchSucceeded;
var wasRateLimited = versionProcess.wasRateLimited || qsVersionProcess.wasRateLimited || contributorsProcess.wasRateLimited;
if (anySucceeded) {
root.saveData();
Logger.d("GitHub", "Successfully fetched data from GitHub");
} else if (wasRateLimited) {
root.saveData();
Logger.w("GitHub", "API rate limited - using cached data (retry in", Math.round(githubUpdateFrequency / 60), "minutes)");
} else {
Logger.w("GitHub", "API request failed - using cached data without updating timestamp");
}
// Reset fetch flags for next time
versionProcess.fetchSucceeded = false;
versionProcess.wasRateLimited = false;
qsVersionProcess.fetchSucceeded = false;
qsVersionProcess.wasRateLimited = false;
contributorsProcess.fetchSucceeded = false;
contributorsProcess.wasRateLimited = false;
}
}
// --------------------------------
function resetCache() {
data.version = I18n.tr("common.unknown");
data.qsVersion = I18n.tr("common.unknown");
data.contributors = [];
data.timestamp = 0;
// Try to fetch immediately
fetchFromGitHub();
}
// --------------------------------
// Avatar Caching Functions (simplified - uses ImageCacheService)
// --------------------------------
function getAvatarPath(username) {
return cachedAvatars[username] || "";
}
function cacheTopContributorAvatars() {
if (contributors.length === 0)
return;
avatarsCached = true;
for (var i = 0; i < Math.min(contributors.length, 20); i++) {
var contributor = contributors[i];
var username = contributor.login;
var avatarUrl = contributor.avatar_url;
// Use closure to capture username
(function (uname, url) {
ImageCacheService.getCircularAvatar(url, uname, function (cachedPath, success) {
if (success) {
cachedAvatars[uname] = "file://" + cachedPath;
cachedAvatarsChanged();
}
});
})(username, avatarUrl);
}
}
// --------------------------------
// Hook into contributors change - only process once
onContributorsChanged: {
if (contributors.length > 0 && !avatarsCached && ImageCacheService.initialized) {
Qt.callLater(cacheTopContributorAvatars);
}
}
// Also watch for ImageCacheService to become initialized
Connections {
target: ImageCacheService
function onInitializedChanged() {
if (ImageCacheService.initialized && contributors.length > 0 && !avatarsCached) {
Qt.callLater(cacheTopContributorAvatars);
}
}
}
Process {
id: versionProcess
property bool fetchSucceeded: false
property bool wasRateLimited: false
command: ["curl", "-s", "https://api.github.com/repos/noctalia-dev/noctalia-shell/releases/latest"]
stdout: StdioCollector {
onStreamFinished: {
try {
const response = text;
if (response && response.trim()) {
const data = JSON.parse(response);
if (data.tag_name) {
const version = data.tag_name;
root.data.version = version;
root.latestVersion = version;
versionProcess.fetchSucceeded = true;
Logger.d("GitHub", "Latest version fetched:", version);
} else if (data.message) {
// Check if it's a rate limit error
if (data.message.includes("rate limit")) {
versionProcess.wasRateLimited = true;
} else {
Logger.w("GitHub", "Version API error:", data.message);
}
}
}
} catch (e) {
Logger.e("GitHub", "Failed to parse version response:", e);
}
// Check if all processes are done
checkAndSaveData();
}
}
}
Process {
id: qsVersionProcess
property bool fetchSucceeded: false
property bool wasRateLimited: false
command: ["curl", "-s", "https://api.github.com/repos/noctalia-dev/noctalia-qs/releases/latest"]
stdout: StdioCollector {
onStreamFinished: {
try {
const response = text;
if (response && response.trim()) {
const data = JSON.parse(response);
if (data.tag_name) {
const version = data.tag_name;
root.data.qsVersion = version;
root.latestQSVersion = version;
qsVersionProcess.fetchSucceeded = true;
Logger.d("GitHub", "Latest QS version fetched:", version);
} else if (data.message) {
// Check if it's a rate limit error
if (data.message.includes("rate limit")) {
qsVersionProcess.wasRateLimited = true;
} else {
Logger.w("GitHub", "QS Version API error:", data.message);
}
}
}
} catch (e) {
Logger.e("GitHub", "Failed to parse QS version response:", e);
}
// Check if all processes are done
checkAndSaveData();
}
}
}
Process {
id: contributorsProcess
property bool fetchSucceeded: false
property bool wasRateLimited: false
command: ["curl", "-s", "https://api.github.com/repos/noctalia-dev/noctalia-shell/contributors?per_page=100"]
stdout: StdioCollector {
onStreamFinished: {
try {
const response = text;
Logger.d("GitHub", "Raw contributors response length:", response ? response.length : 0);
if (response && response.trim()) {
const data = JSON.parse(response);
Logger.d("GitHub", "Parsed contributors data type:", typeof data, "length:", Array.isArray(data) ? data.length : "not array");
// Only update if we got a valid array
if (Array.isArray(data)) {
root.data.contributors = data;
root.contributors = root.data.contributors;
contributorsProcess.fetchSucceeded = true;
Logger.d("GitHub", "Contributors fetched:", root.contributors.length);
} else if (data.message) {
// Check if it's a rate limit error
if (data.message.includes("rate limit")) {
contributorsProcess.wasRateLimited = true;
} else {
Logger.w("GitHub", "Contributors API error:", data.message);
}
}
}
} catch (e) {
Logger.e("GitHub", "Failed to parse contributors response:", e);
}
// Check if all processes are done
checkAndSaveData();
}
}
}
}
@@ -0,0 +1,620 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import "../../Helpers/sha256.js" as Crypto
import qs.Commons
Singleton {
id: root
readonly property string pluginsDir: Settings.configDir + "plugins"
readonly property string pluginsFile: Settings.configDir + "plugins.json"
readonly property int currentVersion: 2
// Main source URL - plugins from this source keep plain IDs
readonly property string mainSourceUrl: "https://github.com/noctalia-dev/noctalia-plugins"
Component.onCompleted: {
ensurePluginsDirectory();
ensurePluginsFile();
}
// Generate a short hash (6 characters) from a source URL
function generateSourceHash(sourceUrl) {
var hash = Crypto.sha256(sourceUrl);
return hash.substring(0, 6);
}
// Check if a source is the main Noctalia plugins repository
function isMainSource(sourceUrl) {
return sourceUrl === root.mainSourceUrl;
}
// Generate composite key: plain ID for official, "hash:id" for custom
function generateCompositeKey(pluginId, sourceUrl) {
if (!sourceUrl || isMainSource(sourceUrl)) {
return pluginId;
}
var hash = generateSourceHash(sourceUrl);
return hash + ":" + pluginId;
}
// Parse composite key back to components
function parseCompositeKey(compositeKey) {
var colonIndex = compositeKey.indexOf(":");
// If no colon or colon is after position 6 (hash length), it's a plain ID
if (colonIndex === -1 || colonIndex > 6) {
return {
sourceHash: null,
pluginId: compositeKey,
isOfficial: true
};
}
// Has hash prefix (custom source plugin)
return {
sourceHash: compositeKey.substring(0, colonIndex),
pluginId: compositeKey.substring(colonIndex + 1),
isOfficial: false
};
}
// Get source name by URL
function getSourceNameByUrl(sourceUrl) {
for (var i = 0; i < root.pluginSources.length; i++) {
if (root.pluginSources[i].url === sourceUrl) {
return root.pluginSources[i].name;
}
}
return null;
}
// Get source name by hash
function getSourceNameByHash(hash) {
for (var i = 0; i < root.pluginSources.length; i++) {
if (generateSourceHash(root.pluginSources[i].url) === hash) {
return root.pluginSources[i].name;
}
}
return null;
}
// Get source URL from plugin state
function getPluginSourceUrl(compositeKey) {
var state = root.pluginStates[compositeKey];
return state?.sourceUrl || root.mainSourceUrl;
}
// Signals
signal pluginsChanged
// In-memory plugin cache (populated by scanning disk)
property var installedPlugins: ({}) // { pluginId: manifest }
property var pluginStates: ({}) // { pluginId: { enabled: bool } }
property var pluginSources: [] // Array of { name, url }
property var pluginLoadVersions: ({}) // { pluginId: versionNumber } - for cache busting
// Track async loading
property int pendingManifests: 0
// File storage (minimal - only states and sources)
property FileView pluginsFileView: FileView {
id: pluginsFileView
path: root.pluginsFile
adapter: JsonAdapter {
id: adapter
property int version: root.currentVersion
property var states: ({})
property list<var> sources: []
}
onLoaded: {
Logger.i("PluginRegistry", "Loaded plugin states from:", path);
root.pluginStates = adapter.states || {};
root.pluginSources = adapter.sources || [];
// Ensure default repo is in sources
if (root.pluginSources.length === 0) {
root.pluginSources = [
{
"name": "Noctalia Plugins",
"url": "https://github.com/noctalia-dev/noctalia-plugins",
"enabled": true
}
];
root.save();
}
// Migrate from v1 to v2 (add sourceUrl to states)
root.migratePluginData();
// Scan plugin folder to discover installed plugins
scanPluginFolder();
}
onLoadFailed: function (error) {
Logger.w("PluginRegistry", "Failed to load plugins.json, will create it:", error);
// Initialize defaults and continue
root.pluginStates = {};
root.pluginSources = [
{
"name": "Noctalia Plugins",
"url": "https://github.com/noctalia-dev/noctalia-plugins",
"enabled": true
}
];
// Scan for installed plugins
root.scanPluginFolder();
}
}
function init() {
Logger.d("PluginRegistry", "Initialized");
// Force instantiation of PluginService to set up signal listener
PluginService.initialized;
}
// Migrate plugin data from older versions
function migratePluginData() {
var needsSave = false;
// Migration v1 -> v2: add sourceUrl to states
for (var pluginId in root.pluginStates) {
if (root.pluginStates[pluginId].sourceUrl === undefined) {
Logger.i("PluginRegistry", "Migrating plugin data to v2 (adding sourceUrl)");
var newStates = {};
for (var id in root.pluginStates) {
// For v1 -> v2 migration, we assume plugins are from main source
// Custom plugins installed before this feature need to be reinstalled
newStates[id] = {
enabled: root.pluginStates[id].enabled,
sourceUrl: root.mainSourceUrl
};
}
root.pluginStates = newStates;
needsSave = true;
break;
}
}
// Migration: rename "Official Noctalia Plugins" -> "Noctalia Plugins"
var newSources = [];
var sourcesChanged = false;
for (var i = 0; i < root.pluginSources.length; i++) {
var source = root.pluginSources[i];
if (source.name === "Official Noctalia Plugins") {
newSources.push({
name: "Noctalia Plugins",
url: source.url,
enabled: source.enabled
});
sourcesChanged = true;
Logger.i("PluginRegistry", "Migrating source name: 'Official Noctalia Plugins' -> 'Noctalia Plugins'");
} else {
newSources.push(source);
}
}
if (sourcesChanged) {
root.pluginSources = newSources;
needsSave = true;
}
if (needsSave) {
root.save();
Logger.i("PluginRegistry", "Migration complete");
}
}
// Ensure plugins directory exists
function ensurePluginsDirectory() {
var mkdirProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
command: ["mkdir", "-p", "${root.pluginsDir}"]
}
`, root, "MkdirPlugins");
mkdirProcess.exited.connect(function (exitCode) {
if (exitCode === 0) {
Logger.d("PluginRegistry", "Plugins directory ensured:", root.pluginsDir);
} else {
Logger.e("PluginRegistry", "Failed to create plugins directory");
}
mkdirProcess.destroy();
});
mkdirProcess.running = true;
}
// Ensure plugins.json exists (create minimal one if it doesn't)
function ensurePluginsFile() {
var checkProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
command: ["sh", "-c", "test -f '${root.pluginsFile}' || echo '{\\"version\\":${root.currentVersion},\\"states\\":{},\\"sources\\":[]}' > '${root.pluginsFile}'"]
}
`, root, "EnsurePluginsFile");
checkProcess.exited.connect(function (exitCode) {
if (exitCode === 0) {
Logger.d("PluginRegistry", "Plugins file ensured:", root.pluginsFile);
}
checkProcess.destroy();
});
checkProcess.running = true;
}
// Scan plugin folder to discover installed plugins (single process reads all manifests)
function scanPluginFolder() {
Logger.i("PluginRegistry", "Scanning plugin folder:", root.pluginsDir);
var scanProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
command: ["sh", "-c", "for d in '${root.pluginsDir}'/*/; do [ -d \\"$d\\" ] || continue; [ -f \\"$d/manifest.json\\" ] || continue; echo \\"@@PLUGIN@@$(basename \\"$d\\")\\" ; cat \\"$d/manifest.json\\" ; done"]
stdout: StdioCollector {}
running: true
}
`, root, "ScanAllPlugins");
scanProcess.exited.connect(function (exitCode) {
var output = String(scanProcess.stdout.text || "");
var sections = output.split("@@PLUGIN@@");
var loadedCount = 0;
for (var i = 1; i < sections.length; i++) {
var section = sections[i];
var newlineIdx = section.indexOf('\n');
if (newlineIdx === -1)
continue;
var pluginId = section.substring(0, newlineIdx).trim();
var manifestJson = section.substring(newlineIdx + 1).trim();
if (!pluginId || !manifestJson)
continue;
try {
var manifest = JSON.parse(manifestJson);
var validation = validateManifest(manifest);
if (validation.valid) {
manifest.compositeKey = pluginId;
root.installedPlugins[pluginId] = manifest;
Logger.i("PluginRegistry", "Loaded plugin:", pluginId, "-", manifest.name);
if (!root.pluginStates[pluginId]) {
root.pluginStates[pluginId] = {
enabled: false
};
}
loadedCount++;
} else {
Logger.e("PluginRegistry", "Invalid manifest for", pluginId + ":", validation.error);
}
} catch (e) {
Logger.e("PluginRegistry", "Failed to parse manifest for", pluginId + ":", e.toString());
}
}
Logger.i("PluginRegistry", "All plugin manifests loaded. Total plugins:", loadedCount);
root.pluginsChanged();
scanProcess.destroy();
});
}
// Load a single plugin's manifest from disk
function loadPluginManifest(pluginId) {
var manifestPath = root.pluginsDir + "/" + pluginId + "/manifest.json";
var catProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
command: ["cat", "${manifestPath}"]
stdout: StdioCollector {}
running: true
}
`, root, "LoadManifest_" + pluginId);
catProcess.exited.connect(function (exitCode) {
var output = String(catProcess.stdout.text || "");
if (exitCode === 0 && output) {
try {
var manifest = JSON.parse(output);
var validation = validateManifest(manifest);
if (validation.valid) {
manifest.compositeKey = pluginId;
root.installedPlugins[pluginId] = manifest;
Logger.i("PluginRegistry", "Loaded plugin:", pluginId, "-", manifest.name);
// Ensure state exists (default to disabled)
if (!root.pluginStates[pluginId]) {
root.pluginStates[pluginId] = {
enabled: false
};
}
} else {
Logger.e("PluginRegistry", "Invalid manifest for", pluginId + ":", validation.error);
}
} catch (e) {
Logger.e("PluginRegistry", "Failed to parse manifest for", pluginId + ":", e.toString());
}
} else {
Logger.d("PluginRegistry", "No manifest found for:", pluginId);
}
// Decrement pending count and emit signal when all are done
root.pendingManifests--;
Logger.d("PluginRegistry", "Pending manifests remaining:", root.pendingManifests);
if (root.pendingManifests === 0) {
var installedIds = Object.keys(root.installedPlugins);
Logger.i("PluginRegistry", "All plugin manifests loaded. Total plugins:", installedIds.length);
Logger.d("PluginRegistry", "Installed plugin IDs:", JSON.stringify(installedIds));
root.pluginsChanged();
}
catProcess.destroy();
});
}
// Save registry to disk (only states and sources)
function save() {
adapter.version = root.currentVersion;
adapter.states = root.pluginStates;
adapter.sources = root.pluginSources;
Qt.callLater(() => {
pluginsFileView.writeAdapter();
Logger.d("PluginRegistry", "Plugin states saved");
});
}
// Enable/disable a plugin
function setPluginEnabled(pluginId, enabled) {
if (!root.installedPlugins[pluginId]) {
Logger.w("PluginRegistry", "Cannot set state for non-existent plugin:", pluginId);
return;
}
if (!root.pluginStates[pluginId]) {
root.pluginStates[pluginId] = {
enabled: enabled
};
} else {
root.pluginStates[pluginId].enabled = enabled;
}
save();
root.pluginsChanged();
Logger.i("PluginRegistry", "Plugin", pluginId, enabled ? "enabled" : "disabled");
}
// Check if plugin is enabled
function isPluginEnabled(pluginId) {
return root.pluginStates[pluginId]?.enabled || false;
}
// Check if plugin is downloaded/installed
function isPluginDownloaded(pluginId) {
return pluginId in root.installedPlugins;
}
// Get plugin manifest from cache
function getPluginManifest(pluginId) {
return root.installedPlugins[pluginId] || null;
}
// Get ALL installed plugin IDs (discovered from disk)
function getAllInstalledPluginIds() {
return Object.keys(root.installedPlugins);
}
// Get enabled plugin IDs only
function getEnabledPluginIds() {
return Object.keys(root.pluginStates).filter(function (id) {
return root.pluginStates[id].enabled === true;
});
}
// Register a plugin (add to installed plugins after download)
// sourceUrl is required for new plugins to generate composite key
function registerPlugin(manifest, sourceUrl) {
var compositeKey = generateCompositeKey(manifest.id, sourceUrl);
manifest.compositeKey = compositeKey;
root.installedPlugins[compositeKey] = manifest;
// Ensure state exists (default to disabled, store sourceUrl)
if (!root.pluginStates[compositeKey]) {
root.pluginStates[compositeKey] = {
enabled: false,
sourceUrl: sourceUrl || root.mainSourceUrl
};
} else {
// Preserve enabled state but update sourceUrl
root.pluginStates[compositeKey].sourceUrl = sourceUrl || root.mainSourceUrl;
}
save();
root.pluginsChanged();
Logger.i("PluginRegistry", "Registered plugin:", compositeKey);
return compositeKey;
}
// Unregister a plugin (remove from registry)
function unregisterPlugin(pluginId) {
delete root.pluginStates[pluginId];
delete root.installedPlugins[pluginId];
save();
root.pluginsChanged();
Logger.i("PluginRegistry", "Unregistered plugin:", pluginId);
}
// Increment plugin load version (for cache busting when plugin is updated)
function incrementPluginLoadVersion(pluginId) {
var versions = Object.assign({}, root.pluginLoadVersions);
versions[pluginId] = (versions[pluginId] || 0) + 1;
root.pluginLoadVersions = versions;
Logger.d("PluginRegistry", "Incremented load version for", pluginId, "to", versions[pluginId]);
return versions[pluginId];
}
// Remove plugin state (call after deleting plugin folder)
function removePluginState(pluginId) {
delete root.pluginStates[pluginId];
delete root.installedPlugins[pluginId];
save();
root.pluginsChanged();
Logger.i("PluginRegistry", "Removed plugin state:", pluginId);
}
// Add a plugin source
function addPluginSource(name, url) {
for (var i = 0; i < root.pluginSources.length; i++) {
if (root.pluginSources[i].url === url) {
Logger.w("PluginRegistry", "Source already exists:", url);
return false;
}
}
// Create a new array to trigger property change notification
var newSources = root.pluginSources.slice();
newSources.push({
name: name,
url: url,
enabled: true
});
root.pluginSources = newSources;
save();
Logger.i("PluginRegistry", "Added plugin source:", name);
return true;
}
// Remove a plugin source
function removePluginSource(url) {
var newSources = [];
for (var i = 0; i < root.pluginSources.length; i++) {
if (root.pluginSources[i].url !== url) {
newSources.push(root.pluginSources[i]);
}
}
if (newSources.length === root.pluginSources.length) {
Logger.w("PluginRegistry", "Source not found:", url);
return false;
}
root.pluginSources = newSources;
save();
Logger.i("PluginRegistry", "Removed plugin source:", url);
return true;
}
// Set source enabled/disabled state
function setSourceEnabled(url, enabled) {
var newSources = [];
var found = false;
for (var i = 0; i < root.pluginSources.length; i++) {
if (root.pluginSources[i].url === url) {
newSources.push({
name: root.pluginSources[i].name,
url: root.pluginSources[i].url,
enabled: enabled
});
found = true;
} else {
newSources.push(root.pluginSources[i]);
}
}
if (!found) {
Logger.w("PluginRegistry", "Source not found:", url);
return false;
}
root.pluginSources = newSources;
save();
Logger.i("PluginRegistry", "Source", url, enabled ? "enabled" : "disabled");
return true;
}
// Check if source is enabled
function isSourceEnabled(url) {
for (var i = 0; i < root.pluginSources.length; i++) {
if (root.pluginSources[i].url === url) {
return root.pluginSources[i].enabled !== false; // Default to true if not set
}
}
return false;
}
// Get enabled sources only
function getEnabledSources() {
var enabledSources = [];
for (var i = 0; i < root.pluginSources.length; i++) {
if (root.pluginSources[i].enabled !== false) {
enabledSources.push(root.pluginSources[i]);
}
}
return enabledSources;
}
// Get plugin directory path
function getPluginDir(pluginId) {
return root.pluginsDir + "/" + pluginId;
}
// Get plugin settings file path
function getPluginSettingsFile(pluginId) {
return getPluginDir(pluginId) + "/settings.json";
}
// Validate manifest
function validateManifest(manifest) {
if (!manifest) {
return {
valid: false,
error: "Manifest is null or undefined"
};
}
var required = ["id", "name", "version", "author", "description"];
for (var i = 0; i < required.length; i++) {
if (!manifest[required[i]]) {
return {
valid: false,
error: "Missing required field: " + required[i]
};
}
}
if (!manifest.entryPoints) {
return {
valid: false,
error: "Missing 'entryPoints' field"
};
}
// Check version format (simple x.y.z check)
var versionRegex = /^\d+\.\d+\.\d+$/;
if (!versionRegex.test(manifest.version)) {
return {
valid: false,
error: "Invalid version format (must be x.y.z)"
};
}
return {
valid: true,
error: null
};
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,169 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.UI
Singleton {
id: root
property string supporterDataFile: Settings.cacheDir + "supporters.json"
property int updateFrequency: 60 * 60 // 1 hour in seconds
property bool isFetching: false
property bool isInitialized: false
readonly property alias data: adapter
property var supporters: []
property var cachedAvatars: ({}) // username -> file:// path
property bool avatarsCached: false
FileView {
id: supporterDataFileView
path: supporterDataFile
printErrors: false
watchChanges: false
onLoaded: {
if (!root.isInitialized) {
root.isInitialized = true;
loadFromCache();
}
}
onLoadFailed: function (error) {
if (error.toString().includes("No such file") || error === 2) {
root.isInitialized = true;
fetchFromApi();
}
}
JsonAdapter {
id: adapter
property var supporters: []
property real timestamp: 0
}
}
function init() {
Logger.i("Supporter", "Service started");
}
function loadFromCache() {
const now = Time.timestamp;
var needsRefetch = false;
if (!data.timestamp || (now >= data.timestamp + updateFrequency)) {
needsRefetch = true;
Logger.i("Supporter", "Cache expired or missing, scheduling fetch");
} else {
Logger.i("Supporter", "Cache is fresh, using cached data");
}
if (data.supporters && data.supporters.length > 0) {
root.supporters = data.supporters;
Logger.d("Supporter", "Loaded", data.supporters.length, "supporters from cache");
}
if (needsRefetch) {
fetchFromApi();
}
}
function fetchFromApi() {
if (isFetching) {
Logger.d("Supporter", "Already fetching");
return;
}
isFetching = true;
supporterProcess.running = true;
}
function saveData() {
data.timestamp = Time.timestamp;
Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir]);
try {
supporterDataFileView.writeAdapter();
Logger.d("Supporter", "Cache file written successfully");
} catch (error) {
Logger.e("Supporter", "Failed to write cache file:", error);
}
}
function getAvatarPath(username) {
return cachedAvatars[username] || "";
}
function cacheAvatars() {
if (supporters.length === 0)
return;
avatarsCached = true;
for (var i = 0; i < supporters.length; i++) {
var supporter = supporters[i];
var username = supporter.github_username;
// Only cache avatars for supporters with GitHub accounts
if (!username)
continue;
var avatarUrl = "https://github.com/" + username + ".png?size=256";
(function (uname, url) {
ImageCacheService.getCircularAvatar(url, "supporter_" + uname, function (cachedPath, success) {
if (success) {
cachedAvatars[uname] = "file://" + cachedPath;
cachedAvatarsChanged();
}
});
})(username, avatarUrl);
}
}
onSupportersChanged: {
if (supporters.length > 0 && !avatarsCached && ImageCacheService.initialized) {
Qt.callLater(cacheAvatars);
}
}
Connections {
target: ImageCacheService
function onInitializedChanged() {
if (ImageCacheService.initialized && supporters.length > 0 && !avatarsCached) {
Qt.callLater(cacheAvatars);
}
}
}
Process {
id: supporterProcess
command: ["curl", "-s", "https://api.noctalia.dev/supporters"]
stdout: StdioCollector {
onStreamFinished: {
try {
const response = text;
if (response && response.trim()) {
const parsed = JSON.parse(response);
if (Array.isArray(parsed)) {
root.data.supporters = parsed;
root.supporters = parsed;
root.saveData();
Logger.d("Supporter", "Fetched", parsed.length, "supporters");
} else if (parsed.message) {
Logger.w("Supporter", "API error:", parsed.message);
}
}
} catch (e) {
Logger.e("Supporter", "Failed to parse response:", e);
}
root.isFetching = false;
}
}
}
}
@@ -0,0 +1,156 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Compositor
import qs.Services.Noctalia
import qs.Services.System
Singleton {
id: root
property bool initialized: false
property bool isSending: false
property int totalRamGb: 0
property string instanceId: ""
readonly property string telemetryEndpoint: Quickshell.env("NOCTALIA_TELEMETRY_ENDPOINT") || "https://api.noctalia.dev/ping"
function init() {
if (initialized)
return;
initialized = true;
if (!Settings.data.general.telemetryEnabled) {
Logger.d("Telemetry", "Telemetry disabled by user");
return;
}
// Get or generate instance ID from ShellState
instanceId = ShellState.getTelemetryInstanceId();
if (!instanceId) {
instanceId = generateRandomId();
ShellState.setTelemetryInstanceId(instanceId);
Logger.d("Telemetry", "Generated new random instance ID");
} else {
Logger.d("Telemetry", "Using stored instance ID");
}
// Read RAM info, then send ping
memInfoProcess.running = true;
}
function generateRandomId() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
function getInstanceId() {
return instanceId;
}
Process {
id: memInfoProcess
command: ["sh", "-c", "grep MemTotal /proc/meminfo | awk '{print int($2/1048576)}'"]
running: false
stdout: StdioCollector {
onStreamFinished: {
const ramGb = parseInt(text.trim()) || 0;
root.totalRamGb = ramGb;
root.sendPing();
}
}
onExited: function (exitCode) {
if (exitCode !== 0) {
// Still send ping even if RAM detection fails
root.sendPing();
}
}
}
function sendPing() {
if (isSending)
return;
isSending = true;
const payload = {
instanceId: instanceId,
version: UpdateService.currentVersion,
compositor: getCompositorType(),
os: HostService.osPretty || "Unknown",
ramGb: totalRamGb,
monitors: getMonitorInfo(),
ui: {
scaleRatio: Settings.data.general.scaleRatio,
fontDefaultScale: Settings.data.ui.fontDefaultScale,
fontFixedScale: Settings.data.ui.fontFixedScale
}
};
Logger.d("Telemetry", "Sending anonymous ping:", JSON.stringify(payload));
const request = new XMLHttpRequest();
request.onreadystatechange = function () {
if (request.readyState === XMLHttpRequest.DONE) {
if (request.status >= 200 && request.status < 300) {
Logger.d("Telemetry", "Ping sent successfully");
} else {
Logger.d("Telemetry", "Ping failed with status:", request.status);
}
isSending = false;
}
};
request.open("POST", telemetryEndpoint);
request.setRequestHeader("Content-Type", "application/json");
request.send(JSON.stringify(payload));
}
function getMonitorInfo() {
const monitors = [];
const screens = Quickshell.screens || [];
const scales = CompositorService.displayScales || {};
for (let i = 0; i < screens.length; i++) {
const screen = screens[i];
const name = screen.name || "Unknown";
const scaleData = scales[name];
// Extract just the numeric scale value
const scaleValue = (typeof scaleData === "object" && scaleData !== null) ? (scaleData.scale || 1.0) : (scaleData || 1.0);
monitors.push({
width: screen.width || 0,
height: screen.height || 0,
scale: scaleValue
});
}
return monitors;
}
function getCompositorType() {
if (CompositorService.isHyprland)
return "Hyprland";
if (CompositorService.isNiri)
return "Niri";
if (CompositorService.isScroll)
return "Scroll";
if (CompositorService.isSway)
return "Sway";
if (CompositorService.isMango)
return "MangoWC";
if (CompositorService.isLabwc)
return "LabWC";
if (CompositorService.isExtWorkspace)
return "ExtWorkspace";
return "Unknown";
}
}
@@ -0,0 +1,435 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Noctalia
import qs.Services.UI
Singleton {
id: root
// Version properties
readonly property string baseVersion: "4.7.5"
readonly property bool isDevelopment: false
readonly property string developmentSuffix: "-git"
readonly property string currentVersion: `v${!isDevelopment ? baseVersion : baseVersion + developmentSuffix}`
// Telemetry was introduced in this version - users upgrading from earlier need to see the wizard
readonly property string telemetryIntroVersion: "4.0.2"
// URLs
readonly property string discordUrl: "https://discord.noctalia.dev"
readonly property string feedbackUrl: Quickshell.env("NOCTALIA_CHANGELOG_FEEDBACK_URL") || ""
readonly property string upgradeLogBaseUrl: Quickshell.env("NOCTALIA_UPGRADELOG_URL") || "https://api.noctalia.dev/upgradelog"
// Changelog properties
property bool initialized: false
property bool changelogPending: false
property string changelogFromVersion: ""
property string changelogToVersion: ""
property string previousVersion: ""
property string changelogCurrentVersion: ""
property string releaseContent: ""
property string lastShownVersion: ""
property bool popupScheduled: false
property string fetchError: ""
property string changelogLastSeenVersion: ""
property bool changelogStateLoaded: false
property bool pendingShowRequest: false
property bool pendingTelemetryWizardCheck: false
// Fix for FileView race condition
property bool saveInProgress: false
property bool pendingSave: false
property int saveDebounceTimer: 0
Connections {
target: PanelService
function onPopupMenuWindowRegistered(screen) {
if (popupScheduled) {
if (!viewChangelogTargetScreen || viewChangelogTargetScreen.name === screen.name) {
openWhenReady();
}
}
}
}
signal popupQueued(string fromVersion, string toVersion)
signal telemetryWizardNeeded
function init() {
if (initialized)
return;
initialized = true;
Logger.i("UpdateService", "Version:", root.currentVersion);
// Load changelog state from ShellState
Qt.callLater(() => {
if (typeof ShellState !== 'undefined' && ShellState.isLoaded) {
loadChangelogState();
}
});
}
Connections {
target: typeof ShellState !== 'undefined' ? ShellState : null
function onIsLoadedChanged() {
if (ShellState.isLoaded) {
loadChangelogState();
}
}
}
// Debounce timer to prevent rapid successive saves
Timer {
id: saveDebouncer
interval: 300
repeat: false
onTriggered: executeSave()
}
function handleChangelogRequest() {
const fromVersion = changelogFromVersion || "";
const toVersion = changelogToVersion || "";
if (Settings.shouldOpenSetupWizard) {
// If you'll see the setup wizard then you don't need to see the changelog
markChangelogSeen(toVersion);
return;
}
if (!toVersion)
return;
if (popupScheduled && changelogCurrentVersion === toVersion)
return;
if (!popupScheduled && lastShownVersion === toVersion)
return;
previousVersion = fromVersion;
changelogCurrentVersion = toVersion;
// Fetch the upgrade log from the server
fetchUpgradeLog(fromVersion, toVersion);
popupScheduled = true;
root.popupQueued(previousVersion, changelogCurrentVersion);
clearChangelogRequest();
}
function fetchUpgradeLog(fromVersion, toVersion) {
// Normalize and ensure "v" prefix for consistent URL format
let from = ensureVersionPrefix(fromVersion || changelogLastSeenVersion || "3.0.0");
let to = ensureVersionPrefix(toVersion);
// Strip -git suffix
from = from.replace(root.developmentSuffix, "");
to = to.replace(root.developmentSuffix, "");
// 'from' always needs to be before 'to' (use semantic comparison)
if (compareVersions(from, to) >= 0) {
from = "v3.0.0";
}
const url = `${upgradeLogBaseUrl}/${from}/${to}`;
Logger.i("UpdateService", "Fetching upgrade log:", url);
const request = new XMLHttpRequest();
request.onreadystatechange = function () {
if (request.readyState === XMLHttpRequest.DONE) {
Logger.d("UpdateService", "Request completed with status:", request.status);
Logger.d("UpdateService", "Response text length:", request.responseText ? request.responseText.length : 0);
if (request.status >= 200 && request.status < 300) {
releaseContent = request.responseText || "";
Logger.d("UpdateService", "Successfully fetched upgrade log");
fetchError = "";
openWhenReady();
} else {
Logger.w("UpdateService", "Failed to fetch upgrade log, status:", request.status);
releaseContent = "";
if (request.status === 404) {
// Changelog not available for this version range - skip silently
Logger.w("UpdateService", "Changelog not found, skipping display");
fetchError = "";
popupScheduled = false;
markChangelogSeen(toVersion);
} else {
// Network error or server issue - show error to user
fetchError = I18n.tr("changelog.error.fetch-failed");
openWhenReady();
}
}
}
};
request.open("GET", url);
request.send();
}
function normalizeVersion(version) {
if (!version)
return "";
return version.startsWith("v") ? version.substring(1) : version;
}
function ensureVersionPrefix(version) {
if (!version)
return "";
return version.startsWith("v") ? version : "v" + version;
}
function parseVersionParts(version) {
const clean = normalizeVersion(version);
if (!clean)
return [];
return clean.split(/[^0-9]+/).filter(part => part.length > 0).map(part => parseInt(part));
}
function compareVersions(a, b) {
if (a === b)
return 0;
const partsA = parseVersionParts(a);
const partsB = parseVersionParts(b);
const length = Math.max(partsA.length, partsB.length);
for (var i = 0; i < length; i++) {
const valA = partsA[i] || 0;
const valB = partsB[i] || 0;
if (valA > valB)
return 1;
if (valA < valB)
return -1;
}
return 0;
}
// Check if user is upgrading from a version before telemetry was introduced
function shouldShowTelemetryWizard() {
if (!changelogStateLoaded)
return false;
if (Settings.isFreshInstall)
return false;
if (Settings.shouldOpenSetupWizard)
return false;
// No previous version recorded but settings exist - assume upgrading from old version
// (e.g., user deleted shell-state.json but has existing settings)
if (!changelogLastSeenVersion || changelogLastSeenVersion === "")
return true;
// Check if last seen version is before telemetry introduction
return compareVersions(changelogLastSeenVersion, telemetryIntroVersion) < 0;
}
// Called by shell.qml to check for telemetry wizard after init
// If state isn't loaded yet, sets a pending flag and emits telemetryWizardNeeded later
function checkTelemetryWizardOrChangelog() {
Logger.d("UpdateService", "checkTelemetryWizardOrChangelog called, stateLoaded:", changelogStateLoaded);
if (!changelogStateLoaded) {
// State not loaded yet, set pending flags
Logger.d("UpdateService", "State not loaded yet, setting pending flags");
pendingTelemetryWizardCheck = true;
pendingShowRequest = true;
return;
}
// State is already loaded, check immediately
const needsTelemetryWizard = shouldShowTelemetryWizard();
Logger.d("UpdateService", "shouldShowTelemetryWizard:", needsTelemetryWizard, "lastSeenVersion:", changelogLastSeenVersion);
if (needsTelemetryWizard) {
Logger.i("UpdateService", "Emitting telemetryWizardNeeded signal");
root.telemetryWizardNeeded();
} else {
showLatestChangelog();
}
}
function openWhenReady() {
if (!popupScheduled)
return;
if (!Quickshell.screens || Quickshell.screens.length === 0) {
return;
}
let targetScreen = viewChangelogTargetScreen;
if (targetScreen) {
// Explicit screen requested - validate it
if (!PanelService.canShowPanelsOnScreen(targetScreen)) {
Logger.w("UpdateService", "Changelog cannot be shown on screen without bar:", targetScreen.name);
popupScheduled = false;
viewChangelogTargetScreen = null;
return;
}
} else {
// No explicit screen - find one that can show panels
targetScreen = PanelService.findScreenForPanels();
if (!targetScreen) {
Logger.w("UpdateService", "No screen available to show changelog");
popupScheduled = false;
return;
}
}
const panel = PanelService.getPanel("changelogPanel", targetScreen);
if (!panel) {
// Panel not found yet. Wait for popupMenuWindowRegistered signal.
// This avoids the memory leak (#1306).
Logger.d("UpdateService", "Waiting for changelogPanel on screen:", targetScreen.name);
return;
}
panel.open();
popupScheduled = false;
lastShownVersion = changelogCurrentVersion;
viewChangelogTargetScreen = null;
}
function openDiscord() {
if (!discordUrl)
return;
Quickshell.execDetached(["xdg-open", discordUrl]);
}
function openFeedbackForm() {
if (!feedbackUrl)
return;
Quickshell.execDetached(["xdg-open", feedbackUrl]);
}
function showLatestChangelog() {
if (!currentVersion)
return;
if (!changelogStateLoaded) {
pendingShowRequest = true;
return;
}
// Normalize versions for comparison (strip -git, ensure v prefix)
const lastSeen = ensureVersionPrefix(changelogLastSeenVersion.replace(developmentSuffix, ""));
const target = ensureVersionPrefix(currentVersion.replace(developmentSuffix, ""));
if (lastSeen === target)
return;
if (!Settings.data.general.showChangelogOnStartup) {
// user has opted out of seeing changelogs, mark as seen
markChangelogSeen(target);
return;
}
changelogFromVersion = lastSeen;
changelogToVersion = target;
changelogPending = true;
handleChangelogRequest();
}
// Manual changelog viewing (e.g., from Settings > About > View Changelog)
// Shows all changes since v3.0.0, unlike showLatestChangelog() which uses lastSeenVersion
property var viewChangelogTargetScreen: null
function viewChangelog(screen) {
if (!currentVersion)
return;
const target = ensureVersionPrefix(currentVersion.replace(developmentSuffix, ""));
const fromVersion = "v3.8.2";
previousVersion = fromVersion;
changelogCurrentVersion = target;
viewChangelogTargetScreen = screen || null;
popupScheduled = true;
fetchUpgradeLog(fromVersion, target);
}
function clearChangelogRequest() {
changelogPending = false;
changelogFromVersion = "";
changelogToVersion = "";
}
function markChangelogSeen(version) {
if (!version)
return;
changelogLastSeenVersion = version;
debouncedSaveChangelogState();
}
function loadChangelogState() {
try {
const changelog = ShellState.getChangelogState();
changelogLastSeenVersion = changelog.lastSeenVersion || "";
// Migration is now handled in Settings.qml
Logger.d("UpdateService", "Loaded changelog state from ShellState");
} catch (error) {
Logger.e("UpdateService", "Failed to load changelog state:", error);
}
changelogStateLoaded = true;
// Handle pending telemetry wizard check first
if (pendingTelemetryWizardCheck) {
pendingTelemetryWizardCheck = false;
if (shouldShowTelemetryWizard()) {
root.telemetryWizardNeeded();
} else if (pendingShowRequest) {
pendingShowRequest = false;
Qt.callLater(root.showLatestChangelog);
}
return;
}
if (pendingShowRequest) {
pendingShowRequest = false;
Qt.callLater(root.showLatestChangelog);
}
}
function debouncedSaveChangelogState() {
// Queue a save and restart the debounce timer
pendingSave = true;
saveDebouncer.restart();
}
function executeSave() {
if (!pendingSave)
return;
// Prevent concurrent saves
if (saveInProgress) {
// Retry after a short delay
saveDebouncer.start();
return;
}
pendingSave = false;
saveInProgress = true;
try {
ShellState.setChangelogState({
lastSeenVersion: changelogLastSeenVersion || ""
});
Logger.d("UpdateService", "Saved changelog state to ShellState");
saveInProgress = false;
// Check if another save was queued while we were saving
if (pendingSave) {
Qt.callLater(executeSave);
}
} catch (error) {
Logger.e("UpdateService", "Failed to save changelog state:", error);
saveInProgress = false;
}
}
function saveChangelogState() {
// Immediate save (backward compatibility)
debouncedSaveChangelogState();
}
}
@@ -0,0 +1,220 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.UI
Singleton {
id: root
property bool isInhibited: false
property string reason: I18n.tr("system.user-requested")
property var activeInhibitors: []
property var timeout: null // in seconds
// True when the native Wayland IdleInhibitor is handling inhibition
// (set by the IdleInhibitor element in MainScreen via the nativeInhibitor property)
property bool nativeInhibitorAvailable: false
function init() {
Logger.i("IdleInhibitor", "Service started");
}
// Add an inhibitor
function addInhibitor(id, reason = "Application request") {
if (activeInhibitors.includes(id)) {
Logger.w("IdleInhibitor", "Inhibitor already active:", id);
return false;
}
activeInhibitors.push(id);
updateInhibition(reason);
Logger.d("IdleInhibitor", "Added inhibitor:", id);
return true;
}
// Remove an inhibitor
function removeInhibitor(id) {
const index = activeInhibitors.indexOf(id);
if (index === -1) {
Logger.w("IdleInhibitor", "Inhibitor not found:", id);
return false;
}
activeInhibitors.splice(index, 1);
updateInhibition();
Logger.d("IdleInhibitor", "Removed inhibitor:", id);
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 (nativeInhibitorAvailable) {
// Native IdleInhibitor in MainScreen handles it via isInhibited binding
Logger.d("IdleInhibitor", "Native inhibitor active");
} else {
startSubprocessInhibition();
}
isInhibited = true;
Logger.i("IdleInhibitor", "Started inhibition:", reason);
}
// Stop system inhibition
function stopInhibition() {
if (!isInhibited)
return;
if (!nativeInhibitorAvailable && inhibitorProcess.running) {
inhibitorProcess.signal(15); // SIGTERM
}
isInhibited = false;
Logger.i("IdleInhibitor", "Stopped inhibition");
}
// Subprocess fallback using systemd-inhibit
function startSubprocessInhibition() {
inhibitorProcess.command = ["systemd-inhibit", "--what=idle", "--why=" + reason, "--mode=block", "sleep", "infinity"];
inhibitorProcess.running = true;
}
// Process for maintaining the inhibition (subprocess fallback only)
Process {
id: inhibitorProcess
running: false
onExited: function (exitCode, exitStatus) {
if (isInhibited) {
Logger.w("IdleInhibitor", "Inhibitor process exited unexpectedly:", exitCode);
isInhibited = false;
}
}
onStarted: function () {
Logger.d("IdleInhibitor", "Inhibitor process started successfully");
}
}
Timer {
id: inhibitorTimeout
repeat: true
interval: 1000 // 1 second
onTriggered: function () {
if (timeout == null) {
inhibitorTimeout.stop();
return;
}
timeout -= 1;
if (timeout <= 0) {
removeManualInhibitor();
return;
}
}
}
// Manual toggle for user control
function manualToggle() {
// clear any existing timeout
timeout = null;
if (activeInhibitors.includes("manual")) {
removeManualInhibitor();
return false;
} else {
addManualInhibitor(null);
return true;
}
}
function changeTimeout(delta) {
if (timeout == null && delta < 0) {
// no inhibitor, ignored
return;
}
if (timeout == null && delta > 0) {
// enable manual inhibitor and set timeout
addManualInhibitor(timeout + delta);
return;
}
if (timeout + delta <= 0) {
// disable manual inhibitor
removeManualInhibitor();
return;
}
if (timeout + delta > 0) {
// change timeout
addManualInhibitor(timeout + delta);
return;
}
}
function removeManualInhibitor() {
if (timeout !== null) {
timeout = null;
if (inhibitorTimeout.running) {
inhibitorTimeout.stop();
}
}
if (activeInhibitors.includes("manual")) {
removeInhibitor("manual");
ToastService.showNotice(I18n.tr("tooltips.keep-awake"), I18n.tr("common.disabled"), "keep-awake-off");
Logger.i("IdleInhibitor", "Manual inhibition disabled");
}
}
function addManualInhibitor(timeoutSec) {
if (!activeInhibitors.includes("manual")) {
addInhibitor("manual", "Manually activated by user");
ToastService.showNotice(I18n.tr("tooltips.keep-awake"), I18n.tr("common.enabled"), "keep-awake-on");
}
if (timeoutSec === null && timeout === null) {
Logger.i("IdleInhibitor", "Manual inhibition enabled");
return;
} else if (timeoutSec !== null && timeout === null) {
timeout = timeoutSec;
inhibitorTimeout.start();
Logger.i("IdleInhibitor", "Manual inhibition enabled with timeout:", timeoutSec);
return;
} else if (timeoutSec !== null && timeout !== null) {
timeout = timeoutSec;
Logger.i("IdleInhibitor", "Manual inhibition timeout changed to:", timeoutSec);
return;
} else if (timeoutSec === null && timeout !== null) {
timeout = null;
inhibitorTimeout.stop();
Logger.i("IdleInhibitor", "Manual inhibition timeout cleared");
return;
}
}
// Clean up on shutdown
Component.onDestruction: {
stopInhibition();
}
}
@@ -0,0 +1,397 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.Compositor
import qs.Services.UI
/**
* IdleService native idle detection via ext-idle-notify-v1 Wayland protocol.
*
* Three configurable stages:
* 1. Screen-off (DPMS) dims / turns off monitors
* 2. Lock screen activates the session lock
* 3. Suspend systemctl suspend
*
* Each stage shows a fade-to-black overlay for a configurable grace period
* before executing the action. Any mouse movement cancels the fade.
*
* IdleMonitor instances are created with Qt.createQmlObject() so the shell
* does not crash on compositors that lack the protocol.
*
* Timeouts come from Settings.data.idle (in seconds). 0 = disabled.
*/
Singleton {
id: root
// True if ext-idle-notify-v1 is supported by the compositor
readonly property bool nativeIdleMonitorAvailable: _monitorsCreated
// Live idle time in seconds (updated by the 1s heartbeat monitor)
property int idleSeconds: 0
// Fade overlay state "" means no fade in progress
property string fadePending: ""
readonly property int fadeDuration: Settings.data.idle.fadeDuration
property bool _monitorsCreated: false
property var _screenOffMonitor: null
property var _lockMonitor: null
property var _suspendMonitor: null
property var _heartbeatMonitor: null
property var _customMonitors: ({})
property var _queuedStages: []
property bool _screenOffActive: false
// Signals for external listeners (plugins, modules)
signal screenOffRequested
signal lockRequested
signal suspendRequested
// -------------------------------------------------------
function init() {
Logger.i("IdleService", "Service started");
_applyTimeouts();
}
// Grace period timer fires when fade completes without cancellation
Timer {
id: graceTimer
interval: root.fadeDuration * 1000
repeat: false
onTriggered: {
const action = root.fadePending;
root._executeAction(action);
overlayCleanupTimer.start();
}
}
Timer {
id: overlayCleanupTimer
interval: 500
repeat: false
onTriggered: {
root.fadePending = "";
root._runNextQueuedStage();
}
}
// Counts up idleSeconds while the heartbeat monitor reports idle
Timer {
id: idleCounter
interval: 1000
repeat: true
onTriggered: root.idleSeconds++
}
// -------------------------------------------------------
function cancelFade() {
if (fadePending === "") {
_queuedStages = [];
_restoreMonitors();
return;
}
Logger.i("IdleService", "Fade cancelled for:", fadePending);
fadePending = "";
_queuedStages = [];
graceTimer.stop();
overlayCleanupTimer.stop();
_restoreMonitors();
}
function _restoreMonitors() {
if (!_screenOffActive)
return;
_screenOffActive = false;
Logger.i("IdleService", "Restoring monitors (DPMS on)");
CompositorService.turnOnMonitors();
if (Settings.data.idle.resumeScreenOffCommand) {
Logger.i("IdleService", "Executing screen-off resume command");
Quickshell.execDetached(["sh", "-c", Settings.data.idle.resumeScreenOffCommand]);
}
}
function _queueStage(stage) {
if (!_isValidStage(stage)) {
Logger.w("IdleService", "Ignoring unknown queued stage:", stage);
return;
}
if (stage === fadePending)
return;
if (_queuedStages.indexOf(stage) !== -1)
return;
_queuedStages.push(stage);
Logger.d("IdleService", "Queued idle stage while fade is active:", stage);
}
function _isValidStage(stage) {
return stage === "screenOff" || stage === "lock" || stage === "suspend";
}
function _isStageEnabled(stage) {
const idle = Settings.data.idle;
if (stage === "screenOff")
return idle.screenOffTimeout > 0;
if (stage === "lock")
return idle.lockTimeout > 0;
if (stage === "suspend")
return idle.suspendTimeout > 0;
return false;
}
function _runNextQueuedStage() {
if (fadePending !== "")
return;
if (idleSeconds <= 0) {
_queuedStages = [];
return;
}
while (_queuedStages.length > 0) {
const nextStage = _queuedStages.shift();
if (!_isValidStage(nextStage)) {
Logger.w("IdleService", "Dropping queued unknown stage:", nextStage);
continue;
}
if (!_isStageEnabled(nextStage)) {
Logger.d("IdleService", "Dropping queued disabled stage:", nextStage);
continue;
}
Logger.i("IdleService", "Running queued idle stage:", nextStage);
_onIdle(nextStage);
return;
}
}
function _onIdle(stage) {
if (!_isValidStage(stage)) {
Logger.w("IdleService", "Idle fired with unknown stage:", stage);
return;
}
if (!_isStageEnabled(stage)) {
Logger.d("IdleService", "Ignoring idle stage because it is disabled:", stage);
return;
}
if (fadePending !== "") {
_queueStage(stage);
return;
}
Logger.i("IdleService", "Idle fired:", stage);
fadePending = stage;
graceTimer.restart();
}
function _executeAction(stage) {
Logger.i("IdleService", "Executing action:", stage);
if (stage === "screenOff") {
if (Settings.data.idle.screenOffCommand)
Quickshell.execDetached(["sh", "-c", Settings.data.idle.screenOffCommand]);
CompositorService.turnOffMonitors();
root._screenOffActive = true;
root.screenOffRequested();
} else if (stage === "lock") {
if (Settings.data.idle.lockCommand)
Quickshell.execDetached(["sh", "-c", Settings.data.idle.lockCommand]);
if (PanelService.lockScreen && !PanelService.lockScreen.active) {
PanelService.lockScreen.active = true;
}
root.lockRequested();
} else if (stage === "suspend") {
if (Settings.data.idle.suspendCommand)
Quickshell.execDetached(["sh", "-c", Settings.data.idle.suspendCommand]);
if (Settings.data.general.lockOnSuspend) {
CompositorService.lockAndSuspend();
} else {
CompositorService.suspend();
}
root.suspendRequested();
} else {
Logger.w("IdleService", "Unknown idle stage action:", stage);
}
}
// -------------------------------------------------------
// Re-apply when settings change
Connections {
target: Settings
function onSettingsLoaded() {
root._applyTimeouts();
}
}
Connections {
target: Settings.data.idle
function onScreenOffTimeoutChanged() {
root._applyTimeouts();
}
function onLockTimeoutChanged() {
root._applyTimeouts();
}
function onSuspendTimeoutChanged() {
root._applyTimeouts();
}
function onEnabledChanged() {
root._applyTimeouts();
}
function onCustomCommandsChanged() {
root._applyCustomMonitors();
}
}
function _applyTimeouts() {
const idle = Settings.data.idle;
const globalEnabled = idle.enabled;
_setMonitor("screenOff", globalEnabled ? idle.screenOffTimeout : 0);
_setMonitor("lock", globalEnabled ? idle.lockTimeout : 0);
_setMonitor("suspend", globalEnabled ? idle.suspendTimeout : 0);
_ensureHeartbeat();
_applyCustomMonitors();
}
function _applyCustomMonitors() {
// Destroy all existing custom monitors
for (var key in _customMonitors) {
if (_customMonitors[key]) {
_customMonitors[key].destroy();
}
}
root._customMonitors = {};
const idle = Settings.data.idle;
if (!idle.enabled)
return;
var entries = [];
try {
entries = JSON.parse(idle.customCommands);
} catch (e) {
Logger.w("IdleService", "Failed to parse customCommands:", e);
return;
}
var newMonitors = {};
for (var i = 0; i < entries.length; i++) {
const entry = entries[i];
const timeoutSec = parseInt(entry.timeout);
const cmd = entry.command;
const resumeCmd = entry.resumeCommand || "";
if (!cmd && !resumeCmd || timeoutSec <= 0)
continue;
try {
const qml = `
import Quickshell.Wayland
IdleMonitor { timeout: ${timeoutSec} }
`;
const monitor = Qt.createQmlObject(qml, root, "IdleMonitor_custom_" + i);
const capturedCmd = cmd;
const capturedResumeCmd = resumeCmd;
monitor.isIdleChanged.connect(function () {
if (monitor.isIdle) {
if (capturedCmd)
root._executeCustomCommand(capturedCmd);
} else {
if (capturedResumeCmd)
root._executeCustomCommand(capturedResumeCmd);
}
});
newMonitors[i] = monitor;
root._monitorsCreated = true;
Logger.i("IdleService", "Custom monitor " + i + " created, timeout", timeoutSec, "s");
} catch (e) {
Logger.w("IdleService", "Failed to create custom monitor " + i + ":", e);
}
}
root._customMonitors = newMonitors;
}
function _executeCustomCommand(cmd) {
Logger.i("IdleService", "Executing custom command:", cmd);
Quickshell.execDetached(["sh", "-c", cmd]);
}
function _setMonitor(stage, timeoutSec) {
const propName = "_" + stage + "Monitor";
const existing = root[propName];
if (timeoutSec <= 0) {
if (existing) {
existing.destroy();
root[propName] = null;
Logger.d("IdleService", stage + " monitor disabled");
}
return;
}
if (existing) {
if (existing.timeout === timeoutSec)
return;
// ext-idle-notify-v1 has no update-timeout request must recreate
existing.destroy();
root[propName] = null;
Logger.d("IdleService", stage + " monitor timeout changed to", timeoutSec, "s, recreating");
}
try {
const qml = `
import Quickshell.Wayland
IdleMonitor { timeout: ${timeoutSec} }
`;
const monitor = Qt.createQmlObject(qml, root, "IdleMonitor_" + stage);
monitor.isIdleChanged.connect(function () {
if (monitor.isIdle)
root._onIdle(stage);
else
root.cancelFade();
});
root[propName] = monitor;
root._monitorsCreated = true;
Logger.i("IdleService", stage + " monitor created, timeout", timeoutSec, "s");
} catch (e) {
Logger.w("IdleService", "IdleMonitor not available (compositor lacks ext-idle-notify-v1):", e);
root._monitorsCreated = false;
}
}
function _ensureHeartbeat() {
if (_heartbeatMonitor)
return;
try {
const qml = `
import Quickshell.Wayland
IdleMonitor { timeout: 1 }
`;
const monitor = Qt.createQmlObject(qml, root, "IdleMonitor_heartbeat");
monitor.isIdleChanged.connect(function () {
if (monitor.isIdle) {
root.idleSeconds = 1;
idleCounter.start();
} else {
idleCounter.stop();
root.idleSeconds = 0;
if (root.fadePending === "lock" && Settings.data.idle.resumeLockCommand) {
Logger.i("IdleService", "Executing lock resume command");
Quickshell.execDetached(["sh", "-c", Settings.data.idle.resumeLockCommand]);
} else if (root.fadePending === "suspend" && Settings.data.idle.resumeSuspendCommand) {
Logger.i("IdleService", "Executing suspend resume command");
Quickshell.execDetached(["sh", "-c", Settings.data.idle.resumeSuspendCommand]);
}
root.cancelFade();
overlayCleanupTimer.stop();
}
});
_heartbeatMonitor = monitor;
root._monitorsCreated = true;
Logger.d("IdleService", "Heartbeat monitor created");
} catch (e) {
Logger.w("IdleService", "Heartbeat monitor failed:", e);
}
}
}
@@ -0,0 +1,132 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Services.UPower
import qs.Commons
import qs.Services.UI
Singleton {
id: root
readonly property var powerProfiles: PowerProfiles
readonly property bool available: powerProfiles && powerProfiles.hasPerformanceProfile
property int profile: powerProfiles ? powerProfiles.profile : PowerProfile.Balanced
// Not a power profile but a volatile property to quickly disable shadows, animations, etc..
property bool noctaliaPerformanceMode: false
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 init() {
Logger.d("PowerProfileService", "Service started");
}
function setProfile(p) {
if (!available)
return;
try {
powerProfiles.profile = p;
} catch (e) {
Logger.e("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);
}
function cycleProfileReverse() {
if (!available)
return;
const current = powerProfiles.profile;
if (current === PowerProfile.Performance)
setProfile(PowerProfile.Balanced);
else if (current === PowerProfile.Balanced)
setProfile(PowerProfile.PowerSaver);
else if (current === PowerProfile.PowerSaver)
setProfile(PowerProfile.Performance);
}
function isDefault() {
if (!available)
return true;
return (profile === PowerProfile.Balanced);
}
Connections {
target: powerProfiles
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.profile-name", {
"profile": profileName
}), I18n.tr("toast.power-profile.changed"), profileName.toLowerCase().replace(" ", ""));
}
}
}
// Noctalia Performance Mode
// - Turning shadow off
// - Turning animation off
// - Do Not Disturb
function toggleNoctaliaPerformance() {
noctaliaPerformanceMode = !noctaliaPerformanceMode;
}
function setNoctaliaPerformance(value) {
noctaliaPerformanceMode = value;
}
onNoctaliaPerformanceModeChanged: {
if (noctaliaPerformanceMode) {
ToastService.showNotice(I18n.tr("toast.noctalia-performance.label"), I18n.tr("toast.noctalia-performance.enabled"), "rocket");
} else {
ToastService.showNotice(I18n.tr("toast.noctalia-performance.label"), I18n.tr("toast.noctalia-performance.disabled"), "rocket-off");
}
}
}
@@ -0,0 +1,182 @@
pragma Singleton
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Io
import qs.Commons
Singleton {
id: root
property ListModel availableFonts: ListModel {}
property ListModel monospaceFonts: ListModel {}
property bool fontsLoaded: false
property bool isLoading: false
function init() {
if (fontsLoaded || isLoading)
return;
Logger.i("Font", "Service started");
loadFontsViaFcList();
}
// Load all fonts using fc-list in background - no main thread blocking
function loadFontsViaFcList() {
if (isLoading)
return;
isLoading = true;
allFontsProcess.running = true;
}
function populateModels(allFontsText, monoFontsText) {
// Parse monospace fonts into a lookup set
// fc-list returns comma-separated family names for fonts with multiple families
var monoLookup = {};
var monoLines = monoFontsText.split('\n');
for (var i = 0; i < monoLines.length; i++) {
var line = monoLines[i].trim();
if (line) {
var monoFamilies = line.split(',');
for (var mi = 0; mi < monoFamilies.length; mi++) {
var monoName = monoFamilies[mi].trim();
if (monoName)
monoLookup[monoName] = true;
}
}
}
// Parse all fonts - split comma-separated family names
var allLines = allFontsText.split('\n');
var fontSet = {}; // Deduplicate font families
for (var j = 0; j < allLines.length; j++) {
var line = allLines[j].trim();
if (line) {
var families = line.split(',');
for (var fi = 0; fi < families.length; fi++) {
var fontName = families[fi].trim();
if (fontName && !fontSet[fontName]) {
fontSet[fontName] = true;
}
}
}
}
// Sort font names
var sortedFonts = Object.keys(fontSet).sort(function (a, b) {
return a.localeCompare(b);
});
// Build arrays for batch insert
var allBatch = [];
var monoBatch = [];
for (var k = 0; k < sortedFonts.length; k++) {
var name = sortedFonts[k];
var fontObj = {
"key": name,
"name": name
};
allBatch.push(fontObj);
// Check if monospace
if (monoLookup[name] || name.toLowerCase().includes("mono")) {
monoBatch.push(fontObj);
}
}
// Clear and populate models (single batch operation)
availableFonts.clear();
monospaceFonts.clear();
availableFonts.append({
"key": Qt.application.font.family,
"name": I18n.tr("panels.indicator.system-default")
});
monospaceFonts.append({
"key": "monospace",
"name": I18n.tr("panels.indicator.system-default")
});
for (var m = 0; m < allBatch.length; m++)
availableFonts.append(allBatch[m]);
for (var n = 0; n < monoBatch.length; n++)
monospaceFonts.append(monoBatch[n]);
fontsLoaded = true;
isLoading = false;
Logger.i("Font", "Loaded", availableFonts.count, "fonts,", monospaceFonts.count, "monospace");
}
// Temporary storage for process outputs
property string _allFontsOutput: ""
property string _monoFontsOutput: ""
property bool _allFontsDone: false
property bool _monoFontsDone: false
function checkBothProcessesDone() {
if (_allFontsDone && _monoFontsDone) {
populateModels(_allFontsOutput, _monoFontsOutput);
// Clear temp storage
_allFontsOutput = "";
_monoFontsOutput = "";
_allFontsDone = false;
_monoFontsDone = false;
}
}
// Process to get all font families (runs in background)
Process {
id: allFontsProcess
command: ["fc-list", "--format", "%{family}\\n"]
running: false
stdout: StdioCollector {
onStreamFinished: {
root._allFontsOutput = this.text;
root._allFontsDone = true;
root.checkBothProcessesDone();
}
}
onRunningChanged: {
if (running) {
// Start mono fonts process in parallel
monoFontsProcess.running = true;
}
}
onExited: function (exitCode, exitStatus) {
if (exitCode !== 0) {
Logger.w("Font", "fc-list failed with exit code", exitCode);
root._allFontsOutput = "";
root._allFontsDone = true;
root.checkBothProcessesDone();
}
}
}
// Process to get monospace font families (runs in background, parallel)
Process {
id: monoFontsProcess
command: ["fc-list", ":mono", "--format", "%{family}\\n"]
running: false
stdout: StdioCollector {
onStreamFinished: {
root._monoFontsOutput = this.text;
root._monoFontsDone = true;
root.checkBothProcessesDone();
}
}
onExited: function (exitCode, exitStatus) {
if (exitCode !== 0) {
root._monoFontsOutput = "";
root._monoFontsDone = true;
root.checkBothProcessesDone();
}
}
}
}
@@ -0,0 +1,214 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
Singleton {
id: root
// Public properties
property string osPretty: ""
property string osLogo: ""
property bool isNixOS: false
property bool isReady: false
// User info
readonly property string username: (Quickshell.env("USER") || "")
readonly property string envRealName: (Quickshell.env("NOCTALIA_REALNAME") || "")
property string realName: ""
// Machine info
property string hostName: ""
// Internal: pending logo name for fallback after probe fails
property string pendingLogoName: ""
readonly property string displayName: {
// Explicit override
if (envRealName && envRealName.length > 0) {
return envRealName;
}
// Name from getent
if (realName && realName.length > 0) {
return realName;
}
// Fallback: $USER as-is (login names are case-sensitive on some systems)
if (username && username.length > 0) {
return username;
}
// Last resort: placeholder
return "User";
}
function init() {
Logger.i("HostService", "Service started");
}
// Internal helpers
function buildCandidates(name) {
const n = (name || "").trim();
if (!n)
return [];
const sizes = ["512x512", "256x256", "128x128", "64x64", "48x48", "32x32", "24x24", "22x22", "16x16"];
const exts = ["svg", "png"];
const candidates = [];
// pixmaps
for (const ext of exts) {
candidates.push(`/usr/share/pixmaps/${n}.${ext}`);
}
// hicolor scalable and raster sizes
candidates.push(`/usr/share/icons/hicolor/scalable/apps/${n}.svg`);
for (const s of sizes) {
for (const ext of exts) {
candidates.push(`/usr/share/icons/hicolor/${s}/apps/${n}.${ext}`);
}
}
// NixOS hicolor paths
candidates.push(`/run/current-system/sw/share/icons/hicolor/scalable/apps/${n}.svg`);
for (const s of sizes) {
for (const ext of exts) {
candidates.push(`/run/current-system/sw/share/icons/hicolor/${s}/apps/${n}.${ext}`);
}
}
// Generic icon themes under /usr/share/icons (common cases)
for (const ext of exts) {
candidates.push(`/usr/share/icons/${n}.${ext}`);
candidates.push(`/usr/share/icons/${n}/${n}.${ext}`);
candidates.push(`/usr/share/icons/${n}/apps/${n}.${ext}`);
}
return candidates;
}
function resolveLogo(name) {
const n = (name || "").trim();
if (!n)
return;
// First try Quickshell's icon lookup for direct file paths
try {
const path = Quickshell.iconPath(n, "");
if (path && path !== "" && !path.startsWith("image://")) {
// Got a direct file path - use it
const finalPath = path.startsWith("file://") ? path : "file://" + path;
root.osLogo = finalPath;
Logger.d("HostService", "Found logo via icon theme:", root.osLogo);
return;
}
} catch (e) {
// Ignore and continue to manual probe
}
// Try manual probing for hicolor/pixmaps paths
// Store name for fallback to image:// URI if probe fails
root.pendingLogoName = n;
const all = buildCandidates(n);
if (all.length === 0) {
// No candidates, try image:// URI directly
root.osLogo = `image://icon/${n}`;
Logger.d("HostService", "Using theme icon URI:", root.osLogo);
return;
}
const script = all.map(p => `if [ -f "${p}" ]; then echo "${p}"; exit 0; fi`).join("; ") + "; exit 1";
probe.command = ["sh", "-c", script];
probe.running = true;
}
// Read /etc/os-release and trigger resolution
FileView {
id: osInfo
path: "/etc/os-release"
onLoaded: {
try {
const lines = text().split("\n");
const val = k => {
const l = lines.find(x => x.startsWith(k + "="));
return l ? l.split("=")[1].replace(/"/g, "") : "";
};
root.osPretty = val("PRETTY_NAME") || val("NAME");
Logger.i("HostService", "Detected", root.osPretty);
const osId = (val("ID") || "").toLowerCase();
root.isNixOS = osId === "nixos" || (root.osPretty || "").toLowerCase().includes("nixos");
const logoName = val("LOGO");
Logger.i("HostService", "Looking for logo icon:", logoName);
if (logoName) {
resolveLogo(logoName);
}
root.isReady = true;
} catch (e) {
Logger.w("HostService", "failed to read os-release", e);
}
}
}
Process {
id: probe
onExited: code => {
const p = String(stdout.text || "").trim();
if (code === 0 && p) {
root.osLogo = `file://${p}`;
root.pendingLogoName = "";
Logger.d("HostService", "Found", root.osLogo);
} else if (root.pendingLogoName) {
// Manual probe failed, fallback to image:// URI (theme icon)
root.osLogo = `image://icon/${root.pendingLogoName}`;
root.pendingLogoName = "";
Logger.d("HostService", "Using theme icon URI:", root.osLogo);
} else {
root.osLogo = "";
Logger.w("HostService", "No distro logo found");
}
}
stdout: StdioCollector {}
stderr: StdioCollector {}
}
// Resolve GECOS real name once on startup
Process {
id: realNameProcess
command: ["sh", "-c", "getent passwd \"$USER\" | cut -d: -f5 | cut -d, -f1"]
running: true
stdout: StdioCollector {
onStreamFinished: {
const name = String(text || "").trim();
if (name.length > 0) {
root.realName = name;
Logger.i("HostService", "resolved real name", name);
}
}
}
stderr: StdioCollector {}
}
// Resolve hostname from distro-specific locations.
// Prefer /etc/hostname, fallback to Gentoo's /etc/conf.d/hostname.
Process {
id: hostNameProcess
command: ["sh", "-c",
"if [ -r /etc/hostname ]; then sed -n '1p' /etc/hostname; exit 0; fi; if [ -r /etc/conf.d/hostname ]; then v=$(sed -n -E 's/^[[:space:]]*[Hh][Oo][Ss][Tt][Nn][Aa][Mm][Ee][[:space:]]*=[[:space:]]*//p' /etc/conf.d/hostname | sed -n '1p'); v=$(printf '%s' \"$v\" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//; s/^\"//; s/\"$//; s/^\x27//; s/\x27$//'); printf '%s\n' \"$v\"; exit 0; fi; exit 0"]
running: true
stdout: StdioCollector {
onStreamFinished: {
const name = String(text || "").trim();
if (name.length > 0) {
root.hostName = name;
Logger.i("HostService", "resolved hostname", name);
}
}
}
stderr: StdioCollector {}
}
}
@@ -0,0 +1,86 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
Singleton {
id: root
readonly property string rulesFilePath: Settings.configDir + "notification-rules.json"
property var rules: []
property FileView rulesFileView: FileView {
id: rulesFileView
path: root.rulesFilePath
watchChanges: true
printErrors: false
adapter: JsonAdapter {
id: rulesAdapter
property var rules: []
}
onLoaded: {
try {
const parsed = JSON.parse(rulesFileView.text());
const raw = Array.isArray(parsed.rules) ? parsed.rules : [];
root.rules = raw.filter(r => (r.pattern || "").trim() !== "");
} catch (e) {
root.rules = [];
}
}
onLoadFailed: function (error) {
root.rules = [];
}
}
function init() {
}
function save() {
Quickshell.execDetached(["mkdir", "-p", Settings.configDir]);
root.rules = root.rules.filter(r => (r.pattern || "").trim() !== "");
rulesAdapter.rules = root.rules;
rulesFileView.writeAdapter();
}
function evaluate(appName, summary, body) {
const haystack = [appName || "", summary || "", body || ""].join(" ");
for (let i = 0; i < root.rules.length; i++) {
const r = root.rules[i];
const pattern = (r.pattern || "").trim();
if (pattern === "")
continue;
let matched = false;
if (pattern.length >= 3 && pattern.startsWith("/") && pattern.endsWith("/")) {
try {
matched = new RegExp(pattern.slice(1, -1)).test(haystack);
} catch (e) {
Logger.w("NotificationRulesService", "Invalid regex:", pattern, e);
}
} else if (pattern.includes("*")) {
try {
const reStr = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
matched = new RegExp(reStr, "i").test(haystack);
} catch (e) {
matched = haystack.toLowerCase().includes(pattern.toLowerCase());
}
} else {
matched = haystack.toLowerCase().includes(pattern.toLowerCase());
}
if (matched) {
const a = (r.action || "block").toLowerCase();
if (a === "mute" || a === "hide")
return a;
if (a === "silence")
return "hide";
return "block";
}
}
return null;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,337 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Theming
// Service to check if various programs are available on the system
Singleton {
id: root
// Program availability properties
property bool nmcliAvailable: false
property bool bluetoothctlAvailable: false
property bool wlsunsetAvailable: false
property bool gnomeCalendarAvailable: false
property bool pythonAvailable: false
property bool wtypeAvailable: false
// Programs to check - maps property names to commands
readonly property var programsToCheck: ({
"bluetoothctlAvailable": ["sh", "-c", "command -v bluetoothctl"],
"nmcliAvailable": ["sh", "-c", "command -v nmcli"],
"wlsunsetAvailable": ["sh", "-c", "command -v wlsunset"],
"gnomeCalendarAvailable": ["sh", "-c", "command -v gnome-calendar"],
"wtypeAvailable": ["sh", "-c", "command -v wtype"],
"pythonAvailable": ["sh", "-c", "command -v python3"]
})
// Discord client auto-detection
property var availableDiscordClients: []
// Code client auto-detection
property var availableCodeClients: []
// Emacs client auto-detection
property var availableEmacsClients: []
// Signal emitted when all checks are complete
signal checksCompleted
// disable Night Light in settings if wlsunset is not available
onChecksCompleted: {
if (!wlsunsetAvailable && Settings.data.nightLight.enabled) {
Settings.data.nightLight.enabled = false;
}
}
onWlsunsetAvailableChanged: {
if (!wlsunsetAvailable && Settings.data.nightLight.enabled) {
Settings.data.nightLight.enabled = false;
}
}
// Function to detect Discord client by checking config directories
function detectDiscordClient() {
// Build shell script to check each client
var scriptParts = ["available_clients=\"\";"];
for (var i = 0; i < TemplateRegistry.discordClients.length; i++) {
var client = TemplateRegistry.discordClients[i];
var clientName = client.name;
var configPath = client.configPath;
// Use the actual config path from the client, removing ~ prefix
var checkPath = configPath.startsWith("~") ? configPath.substring(2) : configPath.substring(1);
scriptParts.push("if [ -d \"$HOME/" + checkPath + "\" ]; then available_clients=\"$available_clients " + clientName + "\"; fi;");
}
scriptParts.push("echo \"$available_clients\"");
// Use a Process to check directory existence for all clients
discordDetector.command = ["sh", "-c", scriptParts.join(" ")];
discordDetector.running = true;
}
// Process to detect Discord client directories
Process {
id: discordDetector
running: false
onExited: function (exitCode) {
availableDiscordClients = [];
if (exitCode === 0) {
var detectedClients = stdout.text.trim().split(/\s+/).filter(function (client) {
return client.length > 0;
});
if (detectedClients.length > 0) {
// Build list of available clients
for (var i = 0; i < detectedClients.length; i++) {
var clientName = detectedClients[i];
for (var j = 0; j < TemplateRegistry.discordClients.length; j++) {
var client = TemplateRegistry.discordClients[j];
if (client.name === clientName) {
availableDiscordClients.push(client);
break;
}
}
}
Logger.d("ProgramChecker", "Detected Discord clients:", detectedClients.join(", "));
}
}
if (availableDiscordClients.length === 0) {
Logger.d("ProgramChecker", "No Discord clients detected");
}
}
stdout: StdioCollector {}
stderr: StdioCollector {}
}
// Function to detect Code client by checking config directories
function detectCodeClient() {
// Build shell script to check each client
var scriptParts = ["available_clients=\"\";"];
for (var i = 0; i < TemplateRegistry.codeClients.length; i++) {
var client = TemplateRegistry.codeClients[i];
var clientName = client.name;
var configPath = client.configPath;
// Check if the config directory exists
scriptParts.push("if [ -d \"$HOME" + configPath.substring(1) + "\" ]; then available_clients=\"$available_clients " + clientName + "\"; fi;");
}
scriptParts.push("echo \"$available_clients\"");
// Use a Process to check directory existence for all clients
codeDetector.command = ["sh", "-c", scriptParts.join(" ")];
codeDetector.running = true;
}
// Process to detect Code client directories
Process {
id: codeDetector
running: false
onExited: function (exitCode) {
availableCodeClients = [];
if (exitCode === 0) {
var detectedClients = stdout.text.trim().split(/\s+/).filter(function (client) {
return client.length > 0;
});
if (detectedClients.length > 0) {
// Build list of available clients
for (var i = 0; i < detectedClients.length; i++) {
var clientName = detectedClients[i];
for (var j = 0; j < TemplateRegistry.codeClients.length; j++) {
var client = TemplateRegistry.codeClients[j];
if (client.name === clientName) {
availableCodeClients.push(client);
break;
}
}
}
Logger.d("ProgramChecker", "Detected Code clients:", detectedClients.join(", "));
}
}
if (availableCodeClients.length === 0) {
Logger.d("ProgramChecker", "No Code clients detected");
}
}
stdout: StdioCollector {}
stderr: StdioCollector {}
}
// Function to detect Emacs client by checking config directories
function detectEmacsClient() {
// Build shell script to check each client
var scriptParts = ["available_clients=\"\";"];
for (var i = 0; i < TemplateRegistry.emacsClients.length; i++) {
var client = TemplateRegistry.emacsClients[i];
var clientName = client.name;
var configPath = client.path;
// Check if the config directory exists
scriptParts.push("if [ -d \"$HOME" + configPath.substring(1) + "\" ]; then available_clients=\"$available_clients " + clientName + "\"; fi;");
}
scriptParts.push("echo \"$available_clients\"");
// Use a Process to check directory existence for all clients
emacsDetector.command = ["sh", "-c", scriptParts.join(" ")];
emacsDetector.running = true;
}
// Process to detect Emacs client directories
Process {
id: emacsDetector
running: false
onExited: function (exitCode) {
availableEmacsClients = [];
if (exitCode === 0) {
var detectedClients = stdout.text.trim().split(/\s+/).filter(function (client) {
return client.length > 0;
});
if (detectedClients.length > 0) {
// Build list of available clients
for (var i = 0; i < detectedClients.length; i++) {
var clientName = detectedClients[i];
for (var j = 0; j < TemplateRegistry.emacsClients.length; j++) {
var client = TemplateRegistry.emacsClients[j];
if (client.name === clientName) {
availableEmacsClients.push(client);
break;
}
}
}
Logger.d("ProgramChecker", "Detected Emacs clients:", detectedClients.join(", "));
}
}
if (availableEmacsClients.length === 0) {
Logger.d("ProgramChecker", "No Emacs clients detected");
}
}
stdout: StdioCollector {}
stderr: StdioCollector {}
}
// Internal tracking
property int completedChecks: 0
property int totalChecks: Object.keys(programsToCheck).length
// Single reusable Process object
Process {
id: checker
running: false
property string currentProperty: ""
onExited: function (exitCode) {
// Set the availability property
root[currentProperty] = (exitCode === 0);
// Stop the process to free resources
running = false;
// Track completion
root.completedChecks++;
// Check next program or emit completion signal
if (root.completedChecks >= root.totalChecks) {
// Run Discord, Code and Emacs client detection after all checks are complete
root.detectDiscordClient();
root.detectCodeClient();
root.detectEmacsClient();
root.checksCompleted();
} else {
root.checkNextProgram();
}
}
stdout: StdioCollector {}
stderr: StdioCollector {}
}
// Queue of programs to check
property var checkQueue: []
property int currentCheckIndex: 0
// Function to check the next program in the queue
function checkNextProgram() {
if (currentCheckIndex >= checkQueue.length)
return;
var propertyName = checkQueue[currentCheckIndex];
var command = programsToCheck[propertyName];
checker.currentProperty = propertyName;
checker.command = command;
checker.running = true;
currentCheckIndex++;
}
// Function to run all program checks
function checkAllPrograms() {
// Reset state
completedChecks = 0;
currentCheckIndex = 0;
checkQueue = Object.keys(programsToCheck);
// Start first check
if (checkQueue.length > 0) {
checkNextProgram();
}
}
// Function to check a specific program
function checkProgram(programProperty) {
if (!programsToCheck.hasOwnProperty(programProperty)) {
Logger.w("ProgramChecker", "Unknown program property:", programProperty);
return;
}
checker.currentProperty = programProperty;
checker.command = programsToCheck[programProperty];
checker.running = true;
}
// Manual function to test Discord detection (for debugging)
function testDiscordDetection() {
Logger.d("ProgramChecker", "Testing Discord detection...");
Logger.d("ProgramChecker", "HOME:", Quickshell.env("HOME"));
// Test each client directory
for (var i = 0; i < TemplateRegistry.discordClients.length; i++) {
var client = TemplateRegistry.discordClients[i];
var configDir = client.configPath.replace("~", Quickshell.env("HOME"));
Logger.d("ProgramChecker", "Checking:", configDir);
}
detectDiscordClient();
}
// Initialize checks when service is created
Component.onCompleted: {
checkAllPrograms();
}
}
@@ -0,0 +1,205 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Commons
Singleton {
id: root
// Map to track active sound players: resolvedPath -> MediaPlayer instance
property var activePlayers: ({})
// Check if QtMultimedia is available
property bool multimediaAvailable: false
// Container for dynamically created players
Item {
id: playersContainer
}
Component.onCompleted: {
// Test if QtMultimedia is available by trying to create a simple component
try {
var testComponent = Qt.createQmlObject(`
import QtQuick
import QtMultimedia
Item {}
`, root, "MultimediaTest");
if (testComponent) {
multimediaAvailable = true;
testComponent.destroy();
Logger.i("SoundService", "QtMultimedia found - sound playback enabled");
}
} catch (e) {
multimediaAvailable = false;
Logger.w("SoundService", "QtMultimedia not available - no audio will be played from noctalia-shell");
}
}
function resolvePath(soundPath) {
if (!soundPath || soundPath === "") {
return "";
}
let resolvedPath = soundPath;
// If it's just a filename (no path separators), assume it's in Assets/Sounds/
if (!soundPath.includes("/") && !soundPath.startsWith("file://")) {
resolvedPath = Quickshell.shellDir + "/Assets/Sounds/" + soundPath;
} else if (!soundPath.startsWith("/") && !soundPath.startsWith("file://")) {
// Relative path - assume it's relative to shellDir
resolvedPath = Quickshell.shellDir + "/" + soundPath;
} else if (soundPath.startsWith("file://")) {
resolvedPath = soundPath.substring(7); // Remove "file://" prefix
}
// Absolute paths are used as-is
return resolvedPath;
}
function playSound(soundPath, options) {
if (!soundPath || soundPath === "") {
Logger.w("SoundService", "No sound path provided");
return;
}
if (!multimediaAvailable) {
Logger.d("SoundService", "QtMultimedia not available, cannot play sound:", soundPath);
return;
}
const opts = options || {};
const volume = opts.volume !== undefined ? opts.volume : 1.0;
const fallback = opts.fallback !== undefined ? opts.fallback : false;
const repeat = opts.repeat !== undefined ? opts.repeat : false;
// Resolve path
const resolvedPath = resolvePath(soundPath);
// Stop any existing player for this path if it's looping
if (repeat && activePlayers[resolvedPath]) {
stopSound(soundPath);
}
// Create MediaPlayer instance dynamically with QtMultimedia import
const loopsValue = repeat ? "MediaPlayer.Infinite" : "1";
const escapedPath = resolvedPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const playerQml = `
import QtQuick
import QtMultimedia
import Quickshell
import qs.Commons
import qs.Services.System
MediaPlayer {
id: mediaPlayer
property string resolvedPath: "${escapedPath}"
property bool shouldFallback: ${fallback && !repeat}
property real soundVolume: ${Math.max(0, Math.min(1, volume))}
source: "file://${escapedPath}"
loops: ${loopsValue}
audioOutput: AudioOutput {
volume: soundVolume
}
onErrorOccurred: {
Logger.w("SoundService", "Error playing sound:", source, error, errorString);
if (shouldFallback) {
const fallbackPath = Quickshell.shellDir + "/Assets/Sounds/notification.mp3";
if (fallbackPath !== resolvedPath) {
SoundService.playSound(fallbackPath, {
volume: soundVolume,
fallback: false,
repeat: false
});
}
}
if (SoundService.activePlayers[resolvedPath]) {
delete SoundService.activePlayers[resolvedPath];
}
destroy();
}
onPlaybackStateChanged: function (state) {
if (state === MediaPlayer.StoppedState && loops === 1) {
if (SoundService.activePlayers[resolvedPath]) {
delete SoundService.activePlayers[resolvedPath];
}
destroy();
}
}
Component.onCompleted: {
play();
}
}
`;
try {
const player = Qt.createQmlObject(playerQml, playersContainer, "MediaPlayer_" + resolvedPath.replace(/[^a-zA-Z0-9]/g, "_"));
if (!player) {
Logger.w("SoundService", "Failed to create MediaPlayer for:", resolvedPath);
// Try fallback if requested
if (fallback && !repeat) {
const defaultSound = Quickshell.shellDir + "/Assets/Sounds/notification.mp3";
if (defaultSound !== resolvedPath) {
playSound(defaultSound, {
volume: volume,
fallback: false,
repeat: false
});
}
}
return;
}
// Store player in activePlayers map
activePlayers[resolvedPath] = player;
Logger.d("SoundService", "Playing sound:", resolvedPath, `(volume: ${Math.round(volume * 100)}%)`, repeat ? "(repeat)" : "");
} catch (e) {
Logger.w("SoundService", "Failed to create MediaPlayer:", e);
// Try fallback if requested
if (fallback && !repeat) {
const defaultSound = Quickshell.shellDir + "/Assets/Sounds/notification.mp3";
if (defaultSound !== resolvedPath) {
playSound(defaultSound, {
volume: volume,
fallback: false,
repeat: false
});
}
}
}
}
function stopSound(soundPath) {
if (!multimediaAvailable) {
// If multimedia isn't available, there are no active players to stop
return;
}
if (soundPath) {
// Resolve path the same way as playSound
const resolvedPath = resolvePath(soundPath);
// Stop and remove the player for this specific sound
if (activePlayers[resolvedPath]) {
const player = activePlayers[resolvedPath];
player.stop();
delete activePlayers[resolvedPath];
player.destroy();
Logger.d("SoundService", "Stopped sound:", resolvedPath);
}
} else {
// Stop all active players (typically used for repeating sounds)
const paths = Object.keys(activePlayers);
for (let i = 0; i < paths.length; i++) {
const path = paths[i];
const player = activePlayers[path];
player.stop();
player.destroy();
}
activePlayers = {};
Logger.d("SoundService", "Stopped all sounds");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.UI
Singleton {
id: root
Connections {
target: WallpaperService
// When the wallpaper changes, regenerate theme if necessary
function onWallpaperChanged(screenName, path) {
var effectiveMonitor = Settings.data.colorSchemes.monitorForColors;
if (effectiveMonitor === "" || effectiveMonitor === undefined) {
effectiveMonitor = Screen.name;
}
if (screenName !== effectiveMonitor)
return;
if (Settings.data.colorSchemes.useWallpaperColors) {
generateFromWallpaper();
} else {
// Re-run predefined scheme templates so {{image}} reflects the new wallpaper path
ColorSchemeService.applyScheme(Settings.data.colorSchemes.predefinedScheme);
}
}
}
Connections {
target: Settings.data.colorSchemes
function onDarkModeChanged() {
Logger.d("AppThemeService", "Detected dark mode change");
generate();
}
function onMonitorForColorsChanged() {
if (Settings.data.colorSchemes.useWallpaperColors) {
Logger.d("AppThemeService", "Monitor for colors changed to:", Settings.data.colorSchemes.monitorForColors);
generateFromWallpaper();
}
}
function onGenerationMethodChanged() {
Logger.d("AppThemeService", "Generation method changed to:", Settings.data.colorSchemes.generationMethod);
generate();
}
}
// PUBLIC FUNCTIONS
function init() {
Logger.i("AppThemeService", "Service started");
}
function generate() {
if (Settings.data.colorSchemes.useWallpaperColors) {
generateFromWallpaper();
} else {
// applyScheme will trigger template generation via schemeReader.onLoaded
ColorSchemeService.applyScheme(Settings.data.colorSchemes.predefinedScheme);
}
}
function generateFromWallpaper() {
var effectiveMonitor = Settings.data.colorSchemes.monitorForColors;
if (effectiveMonitor === "" || effectiveMonitor === undefined) {
effectiveMonitor = Screen.name;
}
const wp = WallpaperService.getWallpaper(effectiveMonitor);
if (!wp) {
Logger.e("AppThemeService", "No wallpaper found for monitor:", effectiveMonitor);
return;
}
const mode = Settings.data.colorSchemes.darkMode ? "dark" : "light";
TemplateProcessor.processWallpaperColors(wp, mode);
}
function generateFromPredefinedScheme(schemeData) {
Logger.i("AppThemeService", "Generating templates from predefined color scheme");
const mode = Settings.data.colorSchemes.darkMode ? "dark" : "light";
var effectiveMonitor = Settings.data.colorSchemes.monitorForColors;
if (effectiveMonitor === "" || effectiveMonitor === undefined) {
effectiveMonitor = Screen.name;
}
const wallpaperPath = WallpaperService.getWallpaper(effectiveMonitor) || "";
TemplateProcessor.processPredefinedScheme(schemeData, mode, wallpaperPath);
}
}
@@ -0,0 +1,286 @@
pragma Singleton
import Qt.labs.folderlistmodel
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Theming
import qs.Services.UI
Singleton {
id: root
property var schemes: []
property bool scanning: false
property string schemesDirectory: Quickshell.shellDir + "/Assets/ColorScheme"
property string downloadedSchemesDirectory: Settings.configDir + "colorschemes"
property string colorsJsonFilePath: Settings.configDir + "colors.json"
readonly property string gtkRefreshScript: Quickshell.shellDir + "/Scripts/python/src/theming/gtk-refresh.py"
// prefer-light/prefer-dark only; GTK template post_hook still runs full gtk-refresh.
function pushSystemColorScheme() {
if (!Settings.data.colorSchemes.syncGsettings)
return;
const mode = Settings.data.colorSchemes.darkMode ? "dark" : "light";
Quickshell.execDetached(["python3", gtkRefreshScript, "--appearance-only", mode]);
}
Connections {
target: Settings.data.colorSchemes
function onDarkModeChanged() {
Logger.d("ColorScheme", "Detected dark mode change");
if (!Settings.data.colorSchemes.useWallpaperColors && Settings.data.colorSchemes.predefinedScheme) {
// Re-apply current scheme to pick the right variant
applyScheme(Settings.data.colorSchemes.predefinedScheme);
}
root.pushSystemColorScheme();
// Toast: dark/light mode switched
const enabled = !!Settings.data.colorSchemes.darkMode;
const label = enabled ? I18n.tr("tooltips.switch-to-dark-mode") : I18n.tr("tooltips.switch-to-light-mode");
const description = I18n.tr("common.enabled");
ToastService.showNotice(label, description, "dark-mode");
}
}
// --------------------------------
function init() {
// does nothing but ensure the singleton is created
// do not remove
Logger.i("ColorScheme", "Service started");
loadColorSchemes();
}
function loadColorSchemes() {
Logger.d("ColorScheme", "Load colorScheme");
scanning = true;
schemes = [];
// Use find command to locate all scheme.json files in both directories
// First ensure the downloaded schemes directory exists
Quickshell.execDetached(["mkdir", "-p", downloadedSchemesDirectory]);
// Find in both preinstalled and downloaded directories
findProcess.command = ["find", "-L", schemesDirectory, downloadedSchemesDirectory, "-mindepth", "2", "-name", "*.json", "-type", "f"];
findProcess.running = true;
}
function getBasename(path) {
if (!path)
return "";
var chunks = path.split("/");
// Get the filename without extension
var filename = chunks[chunks.length - 1];
var schemeName = filename.replace(".json", "");
// Convert back to display names for special cases
if (schemeName === "Noctalia-default") {
return "Noctalia (default)";
} else if (schemeName === "Noctalia-legacy") {
return "Noctalia (legacy)";
} else if (schemeName === "Tokyo-Night") {
return "Tokyo Night";
} else if (schemeName === "Rosepine") {
return "Rose Pine";
}
return schemeName;
}
function resolveSchemePath(nameOrPath) {
if (!nameOrPath)
return "";
if (nameOrPath.indexOf("/") !== -1) {
return nameOrPath;
}
// Handle special cases for Noctalia schemes
var schemeName = nameOrPath.replace(".json", "");
if (schemeName === "Noctalia (default)") {
schemeName = "Noctalia-default";
} else if (schemeName === "Noctalia (legacy)") {
schemeName = "Noctalia-legacy";
} else if (schemeName === "Tokyo Night") {
schemeName = "Tokyo-Night";
} else if (schemeName === "Rose Pine") {
schemeName = "Rosepine";
}
// Check preinstalled directory first, then downloaded directory
var preinstalledPath = schemesDirectory + "/" + schemeName + "/" + schemeName + ".json";
var downloadedPath = downloadedSchemesDirectory + "/" + schemeName + "/" + schemeName + ".json";
// Try to find the scheme in the loaded schemes list to determine which directory it's in
for (var i = 0; i < schemes.length; i++) {
if (schemes[i].indexOf("/" + schemeName + "/") !== -1 || schemes[i].indexOf("/" + schemeName + ".json") !== -1) {
return schemes[i];
}
}
// Fallback: prefer preinstalled, then downloaded
return preinstalledPath;
}
function applyScheme(nameOrPath) {
// Force reload by bouncing the path
var filePath = resolveSchemePath(nameOrPath);
schemeReader.path = "";
schemeReader.path = filePath;
}
function setPredefinedScheme(schemeName) {
Logger.i("ColorScheme", "Attempting to set predefined scheme to:", schemeName);
var resolvedPath = resolveSchemePath(schemeName);
var basename = getBasename(schemeName);
// Check if the scheme actually exists in the loaded schemes list
var schemeExists = false;
for (var i = 0; i < schemes.length; i++) {
if (getBasename(schemes[i]) === basename) {
schemeExists = true;
break;
}
}
if (schemeExists) {
Settings.data.colorSchemes.predefinedScheme = basename;
applyScheme(schemeName);
ToastService.showNotice(I18n.tr("panels.color-scheme.title"), basename, "settings-color-scheme");
} else {
Logger.e("ColorScheme", "Scheme not found:", schemeName);
ToastService.showError(I18n.tr("panels.color-scheme.title"), `'${basename}' ` + I18n.tr("common.not-found"));
}
}
Process {
id: findProcess
running: false
onExited: function (exitCode) {
if (exitCode === 0) {
var output = stdout.text.trim();
var files = output.split('\n').filter(function (line) {
return line.length > 0;
});
files.sort(function (a, b) {
var nameA = getBasename(a).toLowerCase();
var nameB = getBasename(b).toLowerCase();
return nameA.localeCompare(nameB);
});
schemes = files;
scanning = false;
Logger.d("ColorScheme", "Listed", schemes.length, "schemes");
// Normalize stored scheme to basename and re-apply if necessary
var stored = Settings.data.colorSchemes.predefinedScheme;
if (stored) {
var basename = getBasename(stored);
if (basename !== stored) {
Settings.data.colorSchemes.predefinedScheme = basename;
}
if (!Settings.data.colorSchemes.useWallpaperColors) {
applyScheme(basename);
}
}
} else {
Logger.e("ColorScheme", "Failed to find color scheme files");
schemes = [];
scanning = false;
}
}
stdout: StdioCollector {}
stderr: StdioCollector {}
}
// Internal loader to read a scheme file
FileView {
id: schemeReader
onLoaded: {
try {
var data = JSON.parse(text());
var variant = data;
// If scheme provides dark/light variants, pick based on settings
if (data && (data.dark || data.light)) {
if (Settings.data.colorSchemes.darkMode) {
variant = data.dark || data.light;
} else {
variant = data.light || data.dark;
}
}
writeColorsToDisk(variant);
Logger.i("ColorScheme", "Applying color scheme:", getBasename(path));
// Generate templates for predefined color schemes
if (hasEnabledTemplates() || Settings.data.templates.enableUserTheming) {
AppThemeService.generateFromPredefinedScheme(data);
}
} catch (e) {
Logger.e("ColorScheme", "Failed to parse scheme JSON:", path, e);
}
}
}
// Check if any templates are enabled
function hasEnabledTemplates() {
const activeTemplates = Settings.data.templates.activeTemplates;
if (!activeTemplates || activeTemplates.length === 0) {
return false;
}
for (let i = 0; i < activeTemplates.length; i++) {
if (activeTemplates[i].enabled) {
return true;
}
}
return false;
}
// Writer to colors.json using a JsonAdapter for safety
FileView {
id: colorsWriter
path: colorsJsonFilePath
printErrors: false
onSaved:
// Logger.i("ColorScheme", "Colors saved")
{}
JsonAdapter {
id: out
property color mPrimary: "#000000"
property color mOnPrimary: "#000000"
property color mSecondary: "#000000"
property color mOnSecondary: "#000000"
property color mTertiary: "#000000"
property color mOnTertiary: "#000000"
property color mError: "#000000"
property color mOnError: "#000000"
property color mSurface: "#000000"
property color mOnSurface: "#000000"
property color mSurfaceVariant: "#000000"
property color mOnSurfaceVariant: "#000000"
property color mOutline: "#000000"
property color mShadow: "#000000"
property color mHover: "#000000"
property color mOnHover: "#000000"
}
}
function writeColorsToDisk(obj) {
function pick(o, a, b, fallback) {
return (o && (o[a] || o[b])) || fallback;
}
out.mPrimary = pick(obj, "mPrimary", "primary", out.mPrimary);
out.mOnPrimary = pick(obj, "mOnPrimary", "onPrimary", out.mOnPrimary);
out.mSecondary = pick(obj, "mSecondary", "secondary", out.mSecondary);
out.mOnSecondary = pick(obj, "mOnSecondary", "onSecondary", out.mOnSecondary);
out.mTertiary = pick(obj, "mTertiary", "tertiary", out.mTertiary);
out.mOnTertiary = pick(obj, "mOnTertiary", "onTertiary", out.mOnTertiary);
out.mError = pick(obj, "mError", "error", out.mError);
out.mOnError = pick(obj, "mOnError", "onError", out.mOnError);
out.mSurface = pick(obj, "mSurface", "surface", out.mSurface);
out.mOnSurface = pick(obj, "mOnSurface", "onSurface", out.mOnSurface);
out.mSurfaceVariant = pick(obj, "mSurfaceVariant", "surfaceVariant", out.mSurfaceVariant);
out.mOnSurfaceVariant = pick(obj, "mOnSurfaceVariant", "onSurfaceVariant", out.mOnSurfaceVariant);
out.mOutline = pick(obj, "mOutline", "outline", out.mOutline);
out.mShadow = pick(obj, "mShadow", "shadow", out.mShadow);
out.mHover = pick(obj, "mHover", "hover", out.mHover);
out.mOnHover = pick(obj, "mOnHover", "onHover", out.mOnHover);
// Force a rewrite by updating the path
colorsWriter.path = "";
colorsWriter.path = colorsJsonFilePath;
colorsWriter.writeAdapter();
}
}
@@ -0,0 +1,475 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.System
import qs.Services.Theming
import qs.Services.UI
Singleton {
id: root
// Signal emitted when color generation completes successfully (for wallpaper-based theming)
signal colorsGenerated
readonly property string dynamicConfigPath: Settings.cacheDir + "theming.dynamic.toml"
readonly property string templateProcessorScript: Quickshell.shellDir + "/Scripts/python/src/theming/template-processor.py"
// Debounce state for wallpaper processing
property var pendingWallpaperRequest: null
property var pendingPredefinedRequest: null
readonly property var schemeTypes: [
{
"key": "tonal-spot",
"name": "M3-Tonal Spot" // Do not translate
},
{
"key": "content",
"name": "M3-Content" // Do not translate
},
{
"key": "fruit-salad",
"name": "M3-Fruit Salad" // Do not translate
},
{
"key": "rainbow",
"name": "M3-Rainbow" // Do not translate
},
{
"key": "monochrome",
"name": "M3-Monochrome" // Do not translate
},
{
"key": "vibrant",
"name": I18n.tr("common.vibrant")
},
{
"key": "faithful",
"name": I18n.tr("common.faithful")
},
{
"key": "dysfunctional",
"name": I18n.tr("common.dysfunctional")
},
{
"key": "muted",
"name": I18n.tr("common.color-muted")
},
]
// Check if a template is enabled in the activeTemplates array
function isTemplateEnabled(templateId) {
const activeTemplates = Settings.data.templates.activeTemplates;
if (!activeTemplates)
return false;
for (let i = 0; i < activeTemplates.length; i++) {
if (activeTemplates[i].id === templateId && activeTemplates[i].enabled) {
return true;
}
}
return false;
}
function escapeTomlString(value) {
if (!value)
return "";
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/**
* Process wallpaper colors using internal themer
* Dual-path architecture (wallpaper generation)
* Uses debouncing to prevent spawning multiple processes when spamming wallpaper changes
*/
function processWallpaperColors(wallpaperPath, mode) {
Logger.d("TemplateProcessor", `processWallpaperColors called: path=${wallpaperPath}, mode=${mode}`);
pendingWallpaperRequest = {
wallpaperPath: wallpaperPath,
mode: mode
};
pendingPredefinedRequest = null;
debounceTimer.restart();
}
function executeWallpaperColors(wallpaperPath, mode) {
Logger.d("TemplateProcessor", `executeWallpaperColors: path=${wallpaperPath}, mode=${mode}`);
const content = buildThemeConfig();
if (!content && !Settings.data.templates.enableUserTheming) {
Logger.d("TemplateProcessor", "executeWallpaperColors: no config content and no user theming, aborting");
return;
}
const script = buildGenerationScript(content, wallpaperPath, mode);
generateProcess.command = ["sh", "-c", script];
generateProcess.running = true;
}
readonly property string schemeJsonPath: Settings.cacheDir + "predefined-scheme.json"
readonly property string predefinedConfigPath: Settings.cacheDir + "theming.predefined.toml"
/**
* Process predefined color scheme using Python template processor
* Uses --scheme flag to expand 14-color scheme to full 48-color palette
* Uses debouncing to prevent spawning multiple processes when spamming scheme changes
*/
function processPredefinedScheme(schemeData, mode, wallpaperPath) {
pendingPredefinedRequest = {
schemeData: schemeData,
mode: mode,
wallpaperPath: wallpaperPath || ""
};
pendingWallpaperRequest = null;
debounceTimer.restart();
}
function executePredefinedScheme(schemeData, mode, wallpaperPath) {
// 1. Build TOML config for application templates (including terminals)
const tomlContent = buildPredefinedTemplateConfig(mode);
if (!tomlContent && !Settings.data.templates.enableUserTheming) {
Logger.d("TemplateProcessor", "No application templates enabled for predefined scheme");
return;
}
// 3. Build script to write files and run Python
const schemeJsonPathEsc = schemeJsonPath.replace(/'/g, "'\\''");
let script = "";
// Write scheme JSON (needed by both built-in and user templates)
const schemeDelimiter = "SCHEME_JSON_EOF_" + Math.random().toString(36).substr(2, 9);
script += `cat > '${schemeJsonPathEsc}' << '${schemeDelimiter}'\n`;
script += JSON.stringify(schemeData, null, 2) + "\n";
script += `${schemeDelimiter}\n`;
// Run built-in template processor only if there are templates configured
if (tomlContent) {
const configPathEsc = predefinedConfigPath.replace(/'/g, "'\\''");
const tomlDelimiter = "TOML_CONFIG_EOF_" + Math.random().toString(36).substr(2, 9);
// Write TOML config
script += `cat > '${configPathEsc}' << '${tomlDelimiter}'\n`;
script += tomlContent + "\n";
script += `${tomlDelimiter}\n`;
// Run Python template processor with --scheme flag
// Don't pass --mode so templates get both dark and light colors (e.g., zed.json needs both)
// Pass --default-mode so "default" in templates resolves to the current theme mode
// Pass wallpaper as positional arg so image_path is available in templates (no extraction occurs when --scheme is used)
const wpArg = wallpaperPath ? `'${wallpaperPath.replace(/'/g, "'\\''")}'` : "";
script += `python3 "${templateProcessorScript}" ${wpArg} --scheme '${schemeJsonPathEsc}' --config '${configPathEsc}' --default-mode ${mode}\n`;
}
// Add user templates if enabled
script += buildUserTemplateCommandForPredefined(schemeData, mode, wallpaperPath);
generateProcess.command = ["sh", "-c", script];
generateProcess.running = true;
}
/**
* Build TOML config for predefined scheme templates (excludes terminal themes)
*/
function buildPredefinedTemplateConfig(mode) {
var lines = [];
const homeDir = Quickshell.env("HOME");
// Add terminal templates
TemplateRegistry.terminals.forEach(terminal => {
if (isTemplateEnabled(terminal.id)) {
lines.push(`\n[templates.${terminal.id}]`);
lines.push(`input_path = "${Quickshell.shellDir}/Assets/Templates/${terminal.predefinedTemplatePath}"`);
const outputPath = terminal.outputPath.replace("~", homeDir);
lines.push(`output_path = "${outputPath}"`);
const postHookEsc = escapeTomlString(terminal.postHook);
lines.push(`post_hook = "${postHookEsc}"`);
}
});
addApplicationTheming(lines, mode);
if (lines.length > 0) {
return ["[config]"].concat(lines).join("\n") + "\n";
}
return "";
}
// ================================================================================
// WALLPAPER-BASED GENERATION
// ================================================================================
function buildThemeConfig() {
var lines = [];
var mode = Settings.data.colorSchemes.darkMode ? "dark" : "light";
if (Settings.data.colorSchemes.useWallpaperColors) {
addWallpaperTheming(lines, mode);
}
addApplicationTheming(lines, mode);
if (lines.length > 0) {
return ["[config]"].concat(lines).join("\n") + "\n";
}
return "";
}
function addWallpaperTheming(lines, mode) {
const homeDir = Quickshell.env("HOME");
// Noctalia colors JSON
lines.push("[templates.noctalia]");
lines.push('input_path = "' + Quickshell.shellDir + '/Assets/Templates/noctalia.json"');
lines.push('output_path = "' + Settings.configDir + 'colors.json"');
// Terminal templates
TemplateRegistry.terminals.forEach(terminal => {
if (isTemplateEnabled(terminal.id)) {
lines.push(`\n[templates.${terminal.id}]`);
lines.push(`input_path = "${Quickshell.shellDir}/Assets/Templates/${terminal.templatePath}"`);
const outputPath = terminal.outputPath.replace("~", homeDir);
lines.push(`output_path = "${outputPath}"`);
const postHookEsc = escapeTomlString(terminal.postHook);
lines.push(`post_hook = "${postHookEsc}"`);
}
});
}
function addApplicationTheming(lines, mode) {
const homeDir = Quickshell.env("HOME");
TemplateRegistry.applications.forEach(app => {
if (app.id === "discord") {
// Handle Discord clients specially - multiple CSS themes
if (isTemplateEnabled("discord")) {
const inputs = Array.isArray(app.input) ? app.input : [app.input];
inputs.forEach((inputFile, idx) => {
// Derive theme suffix from input filename: discord-midnight.css midnight
const themeSuffix = inputFile.replace(/^discord-/, "").replace(/\.css$/, "");
app.clients.forEach(client => {
if (isDiscordClientEnabled(client.name)) {
lines.push(`\n[templates.discord_${themeSuffix}_${client.name}]`);
lines.push(`input_path = "${Quickshell.shellDir}/Assets/Templates/${inputFile}"`);
// First input uses legacy name for backward compatibility
const outputFile = idx === 0 ? "noctalia.theme.css" : `noctalia-${themeSuffix}.theme.css`;
const outputPath = client.path.replace("~", homeDir) + `/themes/${outputFile}`;
lines.push(`output_path = "${outputPath}"`);
}
});
});
}
} else if (app.id === "code") {
// Handle Code clients specially
if (isTemplateEnabled("code")) {
app.clients.forEach(client => {
// Check if this specific client is detected
var resolvedPaths = TemplateRegistry.resolvedCodeClientPaths(client.name);
if (isCodeClientEnabled(client.name) && resolvedPaths.length > 0) {
resolvedPaths.forEach((resolvedPath, pathIndex) => {
var suffix = resolvedPaths.length > 1 ? `_${pathIndex}` : "";
lines.push(`\n[templates.code_${client.name}${suffix}]`);
lines.push(`input_path = "${Quickshell.shellDir}/Assets/Templates/${app.input}"`);
lines.push(`output_path = "${resolvedPath}"`);
});
}
});
}
} else if (app.id === "emacs") {
if (isTemplateEnabled("emacs")) {
ProgramCheckerService.availableEmacsClients.forEach(client => {
lines.push(`\n[templates.emacs_${client.name}]`);
lines.push(`input_path = "${Quickshell.shellDir}/Assets/Templates/${app.input}"`);
const expandedPath = client.path.replace("~", homeDir) + "/themes/noctalia-theme.el";
lines.push(`output_path = "${expandedPath}"`);
if (app.postProcess) {
const postHook = escapeTomlString(app.postProcess(mode));
lines.push(`post_hook = "${postHook}"`);
}
});
}
} else {
// Handle regular apps
if (isTemplateEnabled(app.id)) {
app.outputs.forEach((output, idx) => {
lines.push(`\n[templates.${app.id}_${idx}]`);
const inputFile = output.input || app.input;
lines.push(`input_path = "${Quickshell.shellDir}/Assets/Templates/${inputFile}"`);
const outputPath = output.path.replace("~", homeDir);
lines.push(`output_path = "${outputPath}"`);
if (app.postProcess) {
const postHook = escapeTomlString(app.postProcess(mode));
lines.push(`post_hook = "${postHook}"`);
}
});
}
}
});
}
function isDiscordClientEnabled(clientName) {
// Check ProgramCheckerService to see if client is detected
for (var i = 0; i < ProgramCheckerService.availableDiscordClients.length; i++) {
if (ProgramCheckerService.availableDiscordClients[i].name === clientName) {
return true;
}
}
return false;
}
function isCodeClientEnabled(clientName) {
// Check ProgramCheckerService to see if client is detected
for (var i = 0; i < ProgramCheckerService.availableCodeClients.length; i++) {
if (ProgramCheckerService.availableCodeClients[i].name === clientName) {
return true;
}
}
return false;
}
// Get scheme type, defaulting to tonal-spot if not a recognized value
function getSchemeType() {
const method = Settings.data.colorSchemes.generationMethod;
const validKeys = root.schemeTypes.map(scheme => scheme.key);
return validKeys.includes(method) ? method : "tonal-spot";
}
function buildGenerationScript(content, wallpaper, mode) {
const pathEsc = dynamicConfigPath.replace(/'/g, "'\\''");
const wpDelimiter = "WALLPAPER_PATH_EOF_" + Math.random().toString(36).substr(2, 9);
// Use heredoc for wallpaper path to avoid all escaping issues
let script = `NOCTALIA_WP_PATH=$(cat << '${wpDelimiter}'\n${wallpaper}\n${wpDelimiter}\n)\n`;
// Run built-in template processor only if there are templates configured
if (content) {
const delimiter = "THEME_CONFIG_EOF_" + Math.random().toString(36).substr(2, 9);
script += `cat > '${pathEsc}' << '${delimiter}'\n${content}\n${delimiter}\n`;
// Use template-processor.py (Python implementation)
// Don't pass --mode so templates get both dark and light colors (e.g., zed.json needs both)
// Pass --default-mode so "default" in templates resolves to the current theme mode
const schemeType = getSchemeType();
script += `python3 "${templateProcessorScript}" "$NOCTALIA_WP_PATH" --scheme-type ${schemeType} --config '${pathEsc}' --default-mode ${mode}\n`;
}
script += buildUserTemplateCommand("$NOCTALIA_WP_PATH", mode);
return script + "\n";
}
// ================================================================================
// USER TEMPLATES, advanced usage
// ================================================================================
function buildUserTemplateCommand(input, mode) {
if (!Settings.data.templates.enableUserTheming)
return "";
const userConfigPath = getUserConfigPath();
let script = "\n# Execute user config if it exists\n";
script += `if [ -f '${userConfigPath}' ]; then\n`;
// If input is a shell variable (starts with $), use double quotes to allow expansion
// Otherwise, use single quotes for safety with file paths
const inputQuoted = input.startsWith("$") ? `"${input}"` : `'${input.replace(/'/g, "'\\''")}'`;
const schemeType = getSchemeType();
// Don't pass --mode so user templates get both dark and light colors
// Pass --default-mode so "default" in templates resolves to the current theme mode
script += ` python3 "${templateProcessorScript}" ${inputQuoted} --scheme-type ${schemeType} --config '${userConfigPath}' --default-mode ${mode}\n`;
script += "fi";
return script;
}
function buildUserTemplateCommandForPredefined(schemeData, mode, wallpaperPath) {
if (!Settings.data.templates.enableUserTheming)
return "";
const userConfigPath = getUserConfigPath();
// Reuse the scheme JSON already written by processPredefinedScheme()
const schemeJsonPathEsc = schemeJsonPath.replace(/'/g, "'\\''");
const wpArg = wallpaperPath ? `'${wallpaperPath.replace(/'/g, "'\\''")}'` : "";
let script = "\n# Execute user templates with predefined scheme colors\n";
script += `if [ -f '${userConfigPath}' ]; then\n`;
// Use --scheme flag with the already-written scheme JSON
// Don't pass --mode so user templates get both dark and light colors
// Pass --default-mode so "default" in templates resolves to the current theme mode
// Pass wallpaper as positional arg so image_path is available in templates
script += ` python3 "${templateProcessorScript}" ${wpArg} --scheme '${schemeJsonPathEsc}' --config '${userConfigPath}' --default-mode ${mode}\n`;
script += "fi";
return script;
}
function getUserConfigPath() {
return (Settings.configDir + "user-templates.toml").replace(/'/g, "'\\''");
}
// ================================================================================
// DEBOUNCE TIMER
// ================================================================================
function executePendingRequest() {
Logger.d("TemplateProcessor", `executePendingRequest: hasWallpaper=${!!pendingWallpaperRequest}, hasPredefined=${!!pendingPredefinedRequest}`);
if (pendingWallpaperRequest) {
const req = pendingWallpaperRequest;
pendingWallpaperRequest = null;
executeWallpaperColors(req.wallpaperPath, req.mode);
} else if (pendingPredefinedRequest) {
const req = pendingPredefinedRequest;
pendingPredefinedRequest = null;
executePredefinedScheme(req.schemeData, req.mode, req.wallpaperPath);
} else {
Logger.d("TemplateProcessor", "executePendingRequest: no pending request");
}
}
Timer {
id: debounceTimer
interval: 150
repeat: false
onTriggered: {
Logger.d("TemplateProcessor", `debounceTimer fired: processRunning=${generateProcess.running}`);
// Kill any running process before starting new one
if (generateProcess.running) {
Logger.d("TemplateProcessor", "debounceTimer: stopping running process");
generateProcess.running = false;
// executePendingRequest will be called from onExited
} else {
executePendingRequest();
}
}
}
// ================================================================================
// PROCESSES
// ================================================================================
Process {
id: generateProcess
workingDirectory: Quickshell.shellDir
running: false
onExited: function (exitCode, exitStatus) {
// Execute any pending request (handles both kill case and debounce timer interval case)
if (pendingWallpaperRequest || pendingPredefinedRequest) {
Logger.d("TemplateProcessor", "generateProcess onExited: has pending request, executing");
executePendingRequest();
} else if (exitCode === 0) {
// No pending request and successful completion - emit signal
root.colorsGenerated();
}
}
stderr: StdioCollector {
onStreamFinished: {
const text = this.text.trim();
if (text && text.includes("Template error:")) {
const errorLines = text.split("\n").filter(l => l.includes("Template error:"));
const errors = errorLines.slice(0, 3).join("\n") + (errorLines.length > 3 ? `\n... (+${errorLines.length - 3} more)` : "");
Logger.w("TemplateProcessor", errors);
ToastService.showWarning(I18n.tr("toast.theming-processor-failed.title"), errors);
}
}
}
}
}
@@ -0,0 +1,619 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
Singleton {
id: root
Component.onCompleted: {
if (Settings.data.templates.enableUserTheming)
writeUserTemplatesToml();
}
readonly property string templateApplyScript: Quickshell.shellDir + '/Scripts/bash/template-apply.sh'
readonly property string gtkRefreshScript: Quickshell.shellDir + '/Scripts/python/src/theming/gtk-refresh.py'
readonly property string vscodeHelperScript: Quickshell.shellDir + '/Scripts/python/src/theming/vscode-helper.py'
// Dynamically resolved VSCode extension theme paths (all matching noctalia extensions)
property var resolvedCodePaths: []
property var resolvedCodiumPaths: []
// Terminal configurations (for wallpaper-based templates)
// Each terminal must define a postHook that sets up config includes and triggers reload
readonly property var terminals: [
{
"id": "foot",
"name": "Foot",
"templatePath": "terminal/foot",
"predefinedTemplatePath": "terminal/foot-predefined",
"outputPath": "~/.config/foot/themes/noctalia",
"postHook": `${templateApplyScript} foot`
},
{
"id": "ghostty",
"name": "Ghostty",
"templatePath": "terminal/ghostty",
"predefinedTemplatePath": "terminal/ghostty-predefined",
"outputPath": "~/.config/ghostty/themes/noctalia",
"postHook": `${templateApplyScript} ghostty`
},
{
"id": "kitty",
"name": "Kitty",
"templatePath": "terminal/kitty.conf",
"predefinedTemplatePath": "terminal/kitty-predefined.conf",
"outputPath": "~/.config/kitty/themes/noctalia.conf",
"postHook": `${templateApplyScript} kitty`
},
{
"id": "alacritty",
"name": "Alacritty",
"templatePath": "terminal/alacritty.toml",
"predefinedTemplatePath": "terminal/alacritty-predefined.toml",
"outputPath": "~/.config/alacritty/themes/noctalia.toml",
"postHook": `${templateApplyScript} alacritty`
},
{
"id": "wezterm",
"name": "Wezterm",
"templatePath": "terminal/wezterm.toml",
"predefinedTemplatePath": "terminal/wezterm-predefined.toml",
"outputPath": "~/.config/wezterm/colors/Noctalia.toml",
"postHook": `${templateApplyScript} wezterm`
}
]
// Application configurations - consolidated from Theming + AppThemeService
readonly property var applications: [
{
"id": "gtk",
"name": "GTK",
"category": "system",
"input": "gtk4.css",
"outputs": [
{
"path": "~/.config/gtk-3.0/noctalia.css",
"input": "gtk3.css"
},
{
"path": "~/.config/gtk-4.0/noctalia.css",
"input": "gtk4.css"
}
],
"postProcess": mode => `python3 ${gtkRefreshScript} ${mode}`
},
{
"id": "qt",
"name": "Qt",
"category": "system",
"input": "qtct.conf",
"outputs": [
{
"path": "~/.config/qt5ct/colors/noctalia.conf"
},
{
"path": "~/.config/qt6ct/colors/noctalia.conf"
}
]
},
{
"id": "kcolorscheme",
"name": "KColorScheme",
"category": "system",
"input": "kcolorscheme.colors",
"outputs": [
{
"path": "~/.local/share/color-schemes/noctalia.colors"
}
],
"postProcess": () => "if command -v plasma-apply-colorscheme >/dev/null 2>&1; then plasma-apply-colorscheme BreezeDark; sleep 0.5; plasma-apply-colorscheme noctalia; fi"
},
{
"id": "fuzzel",
"name": "Fuzzel",
"category": "launcher",
"input": "fuzzel.conf",
"outputs": [
{
"path": "~/.config/fuzzel/themes/noctalia"
}
],
"postProcess": () => `${templateApplyScript} fuzzel`
},
{
"id": "vicinae",
"name": "Vicinae",
"category": "launcher",
"input": "vicinae.toml",
"outputs": [
{
"path": "~/.local/share/vicinae/themes/noctalia.toml"
}
],
"postProcess": () => `cp --update=none ${Quickshell.shellDir}/Assets/noctalia.svg ~/.local/share/vicinae/themes/noctalia.svg && ${templateApplyScript} vicinae`
},
{
"id": "walker",
"name": "Walker",
"category": "launcher",
"input": "walker.css",
"outputs": [
{
"path": "~/.config/walker/themes/noctalia/style.css"
}
],
"postProcess": () => `${templateApplyScript} walker`,
"strict": true // Use strict mode for palette generation (preserves custom surface/outline values)
},
{
"id": "pywalfox",
"name": "Pywalfox",
"category": "browser",
"input": "pywalfox.json",
"outputs": [
{
"path": "~/.cache/wal/colors.json"
}
],
"postProcess": mode => `${templateApplyScript} pywalfox ${mode}`
} // CONSOLIDATED DISCORD CLIENTS
,
{
"id": "discord",
"name": "Discord",
"category": "misc",
"input": ["discord-midnight.css", "discord-material.css"],
"clients": [
{
"name": "vesktop",
"path": "~/.config/vesktop"
},
{
"name": "webcord",
"path": "~/.config/webcord"
},
{
"name": "armcord",
"path": "~/.config/armcord"
},
{
"name": "equibop",
"path": "~/.config/equibop"
},
{
"name": "equicord",
"path": "~/.config/Equicord"
},
{
"name": "lightcord",
"path": "~/.config/lightcord"
},
{
"name": "dorion",
"path": "~/.config/dorion"
},
{
"name": "vencord",
"path": "~/.config/Vencord"
},
{
"name": "vencord-flatpak",
"path": "~/.var/app/com.discordapp.Discord/config/Vencord"
},
{
"name": "betterdiscord",
"path": "~/.config/BetterDiscord"
}
]
},
{
"id": "code",
"name": "VSCode",
"category": "editor",
"input": "code.json",
"clients": [
{
"name": "code",
"path": "~/.vscode/extensions/noctalia.noctaliatheme-0.0.5/themes/NoctaliaTheme-color-theme.json"
},
{
"name": "codium",
"path": "~/.vscode-oss/extensions/noctalia.noctaliatheme-0.0.5-universal/themes/NoctaliaTheme-color-theme.json"
}
]
},
{
"id": "zed",
"name": "Zed",
"category": "editor",
"input": "zed.json",
"outputs": [
{
"path": "~/.config/zed/themes/noctalia.json"
}
],
"dualMode": true // Template contains both dark and light theme patterns
},
{
"id": "helix",
"name": "Helix",
"category": "editor",
"input": "helix.toml",
"outputs": [
{
"path": "~/.config/helix/themes/noctalia.toml"
}
]
},
{
"id": "spicetify",
"name": "Spicetify",
"category": "audio",
"input": "spicetify.ini",
"outputs": [
{
"path": "~/.config/spicetify/Themes/Comfy/color.ini"
}
],
"postProcess": () => `spicetify -q apply --no-restart`
},
{
"id": "telegram",
"name": "Telegram",
"category": "misc",
"input": "telegram.tdesktop-theme",
"outputs": [
{
"path": "~/.config/telegram-desktop/themes/noctalia.tdesktop-theme"
}
]
},
{
"id": "zenBrowser",
"name": "Zen Browser",
"category": "browser",
"input": "zen-browser/zen-userChrome.css",
"outputs": [
{
"path": "~/.cache/noctalia/zen-browser/zen-userChrome.css"
},
{
"path": "~/.cache/noctalia/zen-browser/zen-userContent.css",
"input": "zen-browser/zen-userContent.css"
}
],
"postProcess": ()
=> "sh -c 'CSS_CHROME=\"$HOME/.cache/noctalia/zen-browser/zen-userChrome.css\"; CSS_CONTENT=\"$HOME/.cache/noctalia/zen-browser/zen-userContent.css\"; LINE_CHROME=\"@import \\\"$CSS_CHROME\\\";\"; LINE_CONTENT=\"@import \\\"$CSS_CONTENT\\\";\"; find \"$HOME/.config/zen\" \"$HOME/.zen\" -mindepth 2 -maxdepth 2 -type d -name chrome -print0 2>/dev/null | while IFS= read -r -d \"\" dir; do USER_CHROME=\"$dir/userChrome.css\"; USER_CONTENT=\"$dir/userContent.css\"; mkdir -p \"$dir\"; touch \"$USER_CHROME\" \"$USER_CONTENT\"; sed -i \"/zen-browser\\/zen-userChrome\\.css/d\" \"$USER_CHROME\"; sed -i \"/zen-browser\\/zen-userContent\\.css/d\" \"$USER_CONTENT\"; if ! grep -Fq \"$LINE_CHROME\" \"$USER_CHROME\"; then printf \"%s\\n\" \"$LINE_CHROME\" >> \"$USER_CHROME\"; fi; if ! grep -Fq \"$LINE_CONTENT\" \"$USER_CONTENT\"; then printf \"%s\\n\" \"$LINE_CONTENT\" >> \"$USER_CONTENT\"; fi; done'"
},
{
"id": "cava",
"name": "Cava",
"category": "audio",
"input": "cava.ini",
"outputs": [
{
"path": "~/.config/cava/themes/noctalia"
}
],
"postProcess": () => `${templateApplyScript} cava`
},
{
"id": "yazi",
"name": "Yazi",
"category": "misc",
"input": "yazi.toml",
"outputs": [
{
"path": "~/.config/yazi/flavors/noctalia.yazi/flavor.toml"
}
],
"postProcess": () => `${templateApplyScript} yazi`
},
{
"id": "emacs",
"name": "Emacs",
"category": "editor",
"input": "emacs.el",
"postProcess": () => `emacsclient -e "(load-theme 'noctalia t)"`
},
{
"id": "labwc",
"name": "Labwc",
"category": "compositor",
"input": "labwc.conf",
"outputs": [
{
"path": "~/.config/labwc/themerc-override"
}
],
"postProcess": () => `${templateApplyScript} labwc`
},
{
"id": "niri",
"name": "Niri",
"category": "compositor",
"input": "niri.kdl",
"outputs": [
{
"path": "~/.config/niri/noctalia.kdl"
}
],
"postProcess": () => `${templateApplyScript} niri`
},
{
"id": "sway",
"name": "Sway",
"category": "compositor",
"input": "sway",
"outputs": [
{
"path": "~/.config/sway/noctalia"
}
],
"postProcess": () => `${templateApplyScript} sway`
},
{
"id": "scroll",
"name": "Scroll",
"category": "compositor",
"input": "scroll",
"outputs": [
{
"path": "~/.config/scroll/noctalia"
}
],
"postProcess": () => `${templateApplyScript} scroll`
},
{
"id": "hyprland",
"name": "Hyprland",
"category": "compositor",
"input": "hyprland.conf",
"outputs": [
{
"path": "~/.config/hypr/noctalia/noctalia-colors.conf"
}
],
"postProcess": () => `${templateApplyScript} hyprland`
},
{
"id": "hyprtoolkit",
"name": "Hyprtoolkit",
"category": "system",
"input": "hyprtoolkit.conf",
"outputs": [
{
"path": "~/.config/hypr/hyprtoolkit.conf"
}
]
},
{
"id": "mango",
"name": "Mango",
"category": "compositor",
"input": "mango.conf",
"outputs": [
{
"path": "~/.config/mango/noctalia.conf"
}
],
"postProcess": () => `${templateApplyScript} mango`
},
{
"id": "btop",
"name": "btop",
"category": "misc",
"input": "btop.theme",
"outputs": [
{
"path": "~/.config/btop/themes/noctalia.theme"
}
],
"postProcess": () => `${templateApplyScript} btop`
},
{
"id": "zathura",
"name": "Zathura",
"category": "misc",
"input": "zathurarc",
"outputs": [
{
"path": "~/.config/zathura/noctaliarc"
}
],
"postProcess": () => `${templateApplyScript} zathura`
},
{
"id": "steam",
"name": "Steam",
"category": "misc",
"input": "steam.css",
"outputs": [
{
"path": "~/.steam/steam/steamui/skins/Material-Theme/css/main/colors/matugen.css"
}
]
}
]
// Extract Discord clients for ProgramCheckerService compatibility
readonly property var discordClients: {
var clients = [];
var discordApp = applications.find(app => app.id === "discord");
if (discordApp && discordApp.clients) {
discordApp.clients.forEach(client => {
clients.push({
"name": client.name,
"configPath": client.path,
"themePath": `${client.path}/themes/noctalia.theme.css`
});
});
}
return clients;
}
// Get resolved theme paths for a code client (returns array of all matching paths)
function resolvedCodeClientPaths(clientName) {
if (clientName === "code")
return resolvedCodePaths;
if (clientName === "codium")
return resolvedCodiumPaths;
return [];
}
// Extract Code clients for ProgramCheckerService compatibility
readonly property var codeClients: {
var clients = [];
var codeApp = applications.find(app => app.id === "code");
if (codeApp && codeApp.clients) {
codeApp.clients.forEach(client => {
// Extract base config directory from theme path
var themePath = client.path;
var baseConfigDir = "";
if (client.name === "code") {
// For VSCode: ~/.vscode/extensions/... -> ~/.vscode
baseConfigDir = "~/.vscode";
} else if (client.name === "codium") {
// For VSCodium: ~/.vscode-oss/extensions/... -> ~/.vscode-oss
baseConfigDir = "~/.vscode-oss";
}
clients.push({
"name": client.name,
"configPath": baseConfigDir,
"themePath": "" // resolved dynamically via resolvedCodeClientPaths()
});
});
}
return clients;
}
// Resolve VSCode extension paths dynamically
Process {
id: codeResolverProcess
command: ["python3", vscodeHelperScript, "~/.vscode/extensions"]
running: true
property var paths: []
stdout: SplitParser {
onRead: data => {
var line = data.trim();
if (line)
codeResolverProcess.paths.push(line);
}
}
onExited: {
root.resolvedCodePaths = paths;
}
}
Process {
id: codiumResolverProcess
command: ["python3", vscodeHelperScript, "~/.vscode-oss/extensions"]
running: true
property var paths: []
stdout: SplitParser {
onRead: data => {
var line = data.trim();
if (line)
codiumResolverProcess.paths.push(line);
}
}
onExited: {
root.resolvedCodiumPaths = paths;
}
}
// Build user templates TOML content
function buildUserTemplatesToml() {
var lines = [];
lines.push("[config]");
lines.push("");
lines.push("[templates]");
lines.push("");
lines.push("# User-defined templates");
lines.push("# Add your custom templates below");
lines.push("# Example:");
lines.push("# [templates.myapp]");
lines.push("# input_path = \"~/.config/noctalia/templates/myapp.css\"");
lines.push("# output_path = \"~/.config/myapp/theme.css\"");
lines.push("# post_hook = \"myapp --reload-theme\"");
lines.push("");
lines.push("# Remove this section and add your own templates");
lines.push("#[templates.placeholder]");
lines.push("#input_path = \"" + Quickshell.shellDir + "/Assets/Templates/noctalia.json\"");
lines.push("#output_path = \"" + Settings.cacheDir + "placeholder.json\"");
lines.push("");
return lines.join("\n") + "\n";
}
// Write user templates TOML file (moved from Theming)
function writeUserTemplatesToml() {
var userConfigPath = Settings.configDir + "user-templates.toml";
// Check if file already exists
fileCheckProcess.command = ["test", "-s", userConfigPath];
fileCheckProcess.running = true;
}
function doWriteUserTemplatesToml() {
var userConfigPath = Settings.configDir + "user-templates.toml";
var configContent = buildUserTemplatesToml();
var userConfigPathEsc = userConfigPath.replace(/'/g, "'\\''");
var configDirEsc = Settings.configDir.replace(/'/g, "'\\''");
// Combine mkdir and write in a single script to avoid race condition
var script = `mkdir -p '${configDirEsc}' && cat > '${userConfigPathEsc}' << 'EOF'\n`;
script += configContent;
script += "EOF\n";
fileWriteProcess.command = ["sh", "-c", script];
fileWriteProcess.running = true;
}
// Extract Emacs clients for ProgramCheckerService compatibility
readonly property var emacsClients: [
{
"name": "doom",
"path": "~/.config/doom"
},
{
"name": "modern",
"path": "~/.config/emacs"
},
{
"name": "traditional",
"path": "~/.emacs.d"
}
]
// Process for checking if user templates file exists and is non-empty
Process {
id: fileCheckProcess
running: false
onExited: function (exitCode) {
if (exitCode === 0) {
// File exists and is non-empty, skip creation
Logger.d("TemplateRegistry", "User templates config already exists, skipping creation");
} else {
// File doesn't exist or is empty, create it
doWriteUserTemplatesToml();
}
}
}
// Process for writing user templates file with error reporting
Process {
id: fileWriteProcess
running: false
onExited: function (exitCode) {
if (exitCode === 0) {
Logger.d("TemplateRegistry", "User templates config written to:", Settings.configDir + "user-templates.toml");
} else {
Logger.e("TemplateRegistry", "Failed to write user templates config (exit code:", exitCode + ")");
}
}
}
}
@@ -0,0 +1,668 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.Compositor
import qs.Services.UI
Singleton {
id: root
property bool isVisible: true
// Computed visibility that factors in compositor overview state
readonly property bool effectivelyVisible: {
if (!isVisible) {
return false;
}
if (Settings.data.bar.hideOnOverview && CompositorService.overviewActive) {
return false;
}
return true;
}
property var readyBars: ({})
// Revision counter - increment when widget list structure changes (add/remove/reorder)
// This triggers Bar.qml to re-sync its ListModels
property int widgetsRevision: 0
// Registry to store actual widget instances
// Key format: "screenName|section|widgetId|index"
property var widgetInstances: ({})
signal activeWidgetsChanged
signal barReadyChanged(string screenName)
signal barAutoHideStateChanged(string screenName, bool hidden)
signal barHoverStateChanged(string screenName, bool hovered)
// Track if a popup menu is open from the bar (prevents auto-hide)
property bool popupOpen: false
// Auto-hide state per screen: { screenName: { hovered: bool, hidden: bool } }
property var screenAutoHideState: ({})
// Get or create auto-hide state for a screen
function getOrCreateAutoHideState(screenName) {
if (!screenAutoHideState[screenName]) {
screenAutoHideState[screenName] = {
"hovered": false,
"hidden": Settings.getBarDisplayModeForScreen(screenName) === "auto_hide"
};
}
return screenAutoHideState[screenName];
}
// Set hover state for a screen
function setScreenHovered(screenName, hovered) {
var state = getOrCreateAutoHideState(screenName);
if (state.hovered !== hovered) {
state.hovered = hovered;
screenAutoHideState = Object.assign({}, screenAutoHideState);
barHoverStateChanged(screenName, hovered);
}
}
// Set hidden state for a screen
function setScreenHidden(screenName, hidden) {
var state = getOrCreateAutoHideState(screenName);
if (state.hidden !== hidden) {
state.hidden = hidden;
screenAutoHideState = Object.assign({}, screenAutoHideState);
barAutoHideStateChanged(screenName, hidden);
}
}
// Check if bar is hidden on a screen
function isBarHidden(screenName) {
var state = screenAutoHideState[screenName];
return state ? state.hidden : false;
}
// Check if bar is hovered on a screen
function isBarHovered(screenName) {
var state = screenAutoHideState[screenName];
return state ? state.hovered : false;
}
// Toggle bar visibility. In auto-hide mode, toggles the per-screen hidden
// state without touching isVisible (so hover-to-show still works).
// For non-auto-hide screens, toggles the global isVisible flag.
function toggleVisibility() {
// Check if any auto-hide screen is currently visible
var anyAutoHideVisible = false;
var hasAutoHideScreens = false;
for (var screenName in screenAutoHideState) {
if (Settings.getBarDisplayModeForScreen(screenName) === "auto_hide") {
hasAutoHideScreens = true;
if (!screenAutoHideState[screenName].hidden) {
anyAutoHideVisible = true;
break;
}
}
}
// Toggle auto-hide screens (per-screen hidden state only)
if (hasAutoHideScreens) {
for (var screenName in screenAutoHideState) {
if (Settings.getBarDisplayModeForScreen(screenName) === "auto_hide") {
setScreenHidden(screenName, anyAutoHideVisible);
}
}
}
// Only toggle global visibility when no auto-hide screens exist,
// otherwise it would permanently disable hover-to-show
if (!hasAutoHideScreens) {
isVisible = !isVisible;
}
}
// Show bar. In auto-hide mode, un-hides on screens with auto-hide enabled.
// The bar stays visible until the user hovers and moves away.
function show() {
// Show auto-hide screens
for (var screenName in screenAutoHideState) {
if (Settings.getBarDisplayModeForScreen(screenName) === "auto_hide") {
setScreenHidden(screenName, false);
}
}
// Set global visibility (affects non-auto-hide screens)
isVisible = true;
}
// Hide bar. In auto-hide mode, sets per-screen hidden state without touching
// isVisible so hover-to-show still works. For non-auto-hide screens, sets
// global visibility to false.
function hide() {
var hasAutoHideScreens = false;
for (var screenName in screenAutoHideState) {
if (Settings.getBarDisplayModeForScreen(screenName) === "auto_hide") {
setScreenHidden(screenName, true);
hasAutoHideScreens = true;
}
}
// Only set global visibility off when no auto-hide screens exist,
// otherwise it would permanently disable hover-to-show
if (!hasAutoHideScreens) {
isVisible = false;
}
}
// Temporarily show the bar, then auto-hide after the configured delay.
// Uses the same pattern as workspace switch: show, then emit unhover
// to start the hide timer.
function peek() {
for (var screenName in screenAutoHideState) {
if (Settings.getBarDisplayModeForScreen(screenName) === "auto_hide") {
setScreenHidden(screenName, false);
if (!isBarHovered(screenName)) {
barHoverStateChanged(screenName, false);
}
}
}
}
Component.onCompleted: {
Logger.i("BarService", "Service started");
}
// Bump widgetsRevision when settings are reloaded from an external file change
// so Bar.qml re-syncs its widget ListModels with the updated widget configuration
Connections {
target: Settings
function onSettingsReloaded() {
Logger.d("BarService", "Settings reloaded externally, bumping widgetsRevision");
root.widgetsRevision++;
}
}
// update bar's hidden state when mode changes
Connections {
target: Settings.data.bar
function onDisplayModeChanged() {
Logger.d("BarService", "Display mode changed to:", Settings.data.bar.displayMode);
// Only affect screens without displayMode overrides
for (let screenName in screenAutoHideState) {
if (!Settings.hasScreenOverride(screenName, "displayMode")) {
var displayMode = Settings.getBarDisplayModeForScreen(screenName);
if (displayMode === "auto_hide") {
setScreenHidden(screenName, true);
} else {
if (screenAutoHideState[screenName].hidden) {
setScreenHidden(screenName, false);
}
}
}
}
}
function onScreenOverridesChanged() {
Logger.d("BarService", "Screen overrides changed, re-evaluating auto-hide states");
// Re-evaluate auto-hide state for all screens
for (let screenName in screenAutoHideState) {
var displayMode = Settings.getBarDisplayModeForScreen(screenName);
if (displayMode === "auto_hide") {
if (!screenAutoHideState[screenName].hidden) {
setScreenHidden(screenName, true);
}
} else {
if (screenAutoHideState[screenName].hidden) {
setScreenHidden(screenName, false);
}
}
}
}
}
// Track last workspace ID to detect actual workspace changes
property var lastWorkspaceId: null
// Debounce rapid workspace switches to reduce load/unload races (SIGSEGV in QV4)
property string _pendingWorkspaceScreen: ""
Timer {
id: workspaceDebounceTimer
interval: 80
repeat: false
onTriggered: {
var screen = root._pendingWorkspaceScreen;
root._pendingWorkspaceScreen = "";
if (screen) {
setScreenHidden(screen, false);
if (!root.isBarHovered(screen)) {
barHoverStateChanged(screen, false);
}
}
}
}
// Workspace switch handler - directly show bar on the focused workspace screen
Connections {
target: CompositorService
function onWorkspaceChanged() {
if (!Settings.data.bar.showOnWorkspaceSwitch)
return;
if (Settings.data.bar.displayMode !== "auto_hide")
return;
var ws = CompositorService.getCurrentWorkspace();
if (!ws || !ws.output) {
return;
}
// Only trigger if workspace actually changed
var currentWsId = ws.id;
if (currentWsId === root.lastWorkspaceId) {
return;
}
root.lastWorkspaceId = currentWsId;
var screenName = ws.output || "";
Logger.d("BarService", "Workspace switched to:", currentWsId, "on screen:", screenName);
// Debounce: rapid switches (e.g. external monitor laptop) cause overlapping
// bar load/unload; 80ms delay coalesces them and reduces QV4 incubation races
root._pendingWorkspaceScreen = screenName;
workspaceDebounceTimer.restart();
}
}
// Function for the Bar to call when it's ready
function registerBar(screenName) {
if (!readyBars[screenName]) {
readyBars[screenName] = true;
Logger.d("BarService", "Bar is ready on screen:", screenName);
barReadyChanged(screenName);
}
}
// Function for the Dock to check if the bar is ready
function isBarReady(screenName) {
return readyBars[screenName] || false;
}
// Register a widget instance
function registerWidget(screenName, section, widgetId, index, instance) {
const key = [screenName, section, widgetId, index].join("|");
widgetInstances[key] = {
"key": key,
"screenName": screenName,
"section": section,
"widgetId": widgetId,
"index": index,
"instance": instance
};
Logger.d("BarService", "Registered widget:", key);
root.activeWidgetsChanged();
}
// Unregister a widget instance
function unregisterWidget(screenName, section, widgetId, index) {
const key = [screenName, section, widgetId, index].join("|");
delete widgetInstances[key];
Logger.d("BarService", "Unregistered widget:", key);
root.activeWidgetsChanged();
}
// Lookup a specific widget instance (returns the actual QML instance)
function lookupWidget(widgetId, screenName = null, section = null, index = null) {
// If looking for a specific instance
if (screenName && section !== null) {
for (var key in widgetInstances) {
var widget = widgetInstances[key];
if (!widget)
continue;
if (widget.widgetId === widgetId && widget.screenName === screenName && widget.section === section) {
if (index === null) {
return widget.instance;
} else if (widget.index == index) {
return widget.instance;
}
}
}
}
// Return first match if no specific screen/section specified
for (var key in widgetInstances) {
var widget = widgetInstances[key];
if (!widget)
continue;
if (widget.widgetId === widgetId) {
if (!screenName || widget.screenName === screenName) {
if (section === null || widget.section === section) {
return widget.instance;
}
}
}
}
return undefined;
}
// Get all instances of a widget type
function getAllWidgetInstances(widgetId = null, screenName = null, section = null) {
var instances = [];
for (var key in widgetInstances) {
var widget = widgetInstances[key];
if (!widget)
continue;
var matches = true;
if (widgetId && widget.widgetId !== widgetId)
matches = false;
if (screenName && widget.screenName !== screenName)
matches = false;
if (section !== null && widget.section !== section)
matches = false;
if (matches) {
instances.push(widget.instance);
}
}
return instances;
}
// Get widget with full metadata
function getWidgetWithMetadata(widgetId, screenName = null, section = null) {
for (var key in widgetInstances) {
var widget = widgetInstances[key];
if (!widget)
continue;
if (widget.widgetId === widgetId) {
if (!screenName || widget.screenName === screenName) {
if (section === null || widget.section === section) {
return widget;
}
}
}
}
return undefined;
}
// Get all widgets in a specific section
function getWidgetsBySection(section, screenName = null) {
var widgetEntries = [];
for (var key in widgetInstances) {
var widget = widgetInstances[key];
if (!widget)
continue;
if (widget.section === section) {
if (!screenName || widget.screenName === screenName) {
widgetEntries.push(widget);
}
}
}
// Sort by index to maintain order
widgetEntries.sort(function (a, b) {
return (a.index || 0) - (b.index || 0);
});
// Return just the instances
return widgetEntries.map(function (w) {
return w.instance;
});
}
// Get all registered widgets (for debugging)
function getAllRegisteredWidgets() {
var result = [];
for (var key in widgetInstances) {
var widget = widgetInstances[key];
if (!widget)
continue;
result.push({
"key": key,
"widgetId": widget.widgetId,
"section": widget.section,
"screenName": widget.screenName,
"index": widget.index
});
}
return result;
}
// Check if a widget type exists in a section
function hasWidget(widgetId, section = null, screenName = null) {
for (var key in widgetInstances) {
var widget = widgetInstances[key];
if (!widget)
continue;
if (widget.widgetId === widgetId) {
if (section === null || widget.section === section) {
if (!screenName || widget.screenName === screenName) {
return true;
}
}
}
}
return false;
}
// Unregister all widget instances for a plugin (used during hot reload)
// Note: We don't destroy instances here - the Loader manages that when the component is unregistered
function destroyPluginWidgetInstances(pluginId) {
var widgetId = "plugin:" + pluginId;
var keysToRemove = [];
// Find all instances of this plugin's widget
for (var key in widgetInstances) {
var widget = widgetInstances[key];
if (widget && widget.widgetId === widgetId) {
keysToRemove.push(key);
Logger.d("BarService", "Unregistering plugin widget instance:", key);
}
}
// Remove from registry
for (var i = 0; i < keysToRemove.length; i++) {
delete widgetInstances[keysToRemove[i]];
}
if (keysToRemove.length > 0) {
Logger.i("BarService", "Unregistered", keysToRemove.length, "instance(s) of plugin widget:", widgetId);
root.activeWidgetsChanged();
}
}
// Get pill direction for a widget instance
function getPillDirection(widgetInstance) {
try {
if (widgetInstance.section === "left") {
return false;
} else if (widgetInstance.section === "right") {
return true;
} else {
// middle section
if (widgetInstance.sectionWidgetIndex < widgetInstance.sectionWidgetsCount / 2) {
return true;
} else {
return false;
}
}
} catch (e) {
Logger.e(e);
}
return true;
}
function getTooltipDirection(screenName) {
const position = Settings.getBarPositionForScreen(screenName);
switch (position) {
case "right":
return "left";
case "left":
return "right";
case "bottom":
return "top";
default:
return "bottom";
}
}
// Helper to close any existing dialogs in a popup menu window
function closeExistingDialogs(popupMenuWindow) {
if (!popupMenuWindow || !popupMenuWindow.dialogParent)
return;
var dialogParent = popupMenuWindow.dialogParent;
for (var i = dialogParent.children.length - 1; i >= 0; i--) {
var child = dialogParent.children[i];
if (child && typeof child.close === "function") {
child.close();
}
}
popupMenuWindow.hasDialog = false;
}
// Open widget settings dialog for a bar widget
// Parameters:
// screen: The screen to show the dialog on
// section: Section id ("left", "center", "right")
// index: Widget index in section
// widgetId: Widget type id (e.g., "Volume")
// widgetData: Current widget settings object
function openWidgetSettings(screen, section, index, widgetId, widgetData) {
// Get the popup menu window to use as parent (avoids clipping issues with bar height)
var popupMenuWindow = PanelService.getPopupMenuWindow(screen);
if (!popupMenuWindow) {
Logger.e("BarService", "No popup menu window found for screen");
return;
}
// Close any existing dialogs first to prevent stacking
closeExistingDialogs(popupMenuWindow);
if (PanelService.openedPanel) {
PanelService.openedPanel.close();
}
var component = Qt.createComponent(Quickshell.shellDir + "/Modules/Panels/Settings/Bar/BarWidgetSettingsDialog.qml");
function instantiateAndOpen() {
// Use dialogParent (Item) instead of window directly for proper Popup anchoring
var dialog = component.createObject(popupMenuWindow.dialogParent, {
"widgetIndex": index,
"widgetData": widgetData,
"widgetId": widgetId,
"sectionId": section,
"screen": screen
});
if (dialog) {
dialog.updateWidgetSettings.connect((sec, idx, settings) => {
var screenName = screen?.name || "";
if (Settings.hasScreenOverride(screenName, "widgets")) {
var overrideWidgets = Settings.getBarWidgetsForScreen(screenName);
if (overrideWidgets && overrideWidgets[sec] && idx < overrideWidgets[sec].length) {
overrideWidgets[sec][idx] = Object.assign({}, overrideWidgets[sec][idx], settings);
Settings.setScreenOverride(screenName, "widgets", overrideWidgets);
}
} else {
var widgets = Settings.data.bar.widgets[sec];
if (widgets && idx < widgets.length) {
widgets[idx] = Object.assign({}, widgets[idx], settings);
Settings.data.bar.widgets[sec] = widgets;
Settings.saveImmediate();
}
}
});
// Enable keyboard focus for the popup menu window when dialog is open
popupMenuWindow.hasDialog = true;
// Close the popup menu window when dialog closes
dialog.closed.connect(() => {
popupMenuWindow.hasDialog = false;
popupMenuWindow.close();
dialog.destroy();
});
// Show the popup menu window and open the dialog
popupMenuWindow.open();
dialog.open();
} else {
Logger.e("BarService", "Failed to create widget settings dialog");
}
}
if (component.status === Component.Ready) {
instantiateAndOpen();
} else if (component.status === Component.Error) {
Logger.e("BarService", "Error loading widget settings dialog:", component.errorString());
} else {
component.statusChanged.connect(function () {
if (component.status === Component.Ready) {
instantiateAndOpen();
} else if (component.status === Component.Error) {
Logger.e("BarService", "Error loading widget settings dialog:", component.errorString());
}
});
}
}
// Open plugin settings dialog
// Parameters:
// screen: The screen to show the dialog on
// pluginManifest: The plugin's manifest object (must have entryPoints.settings)
function openPluginSettings(screen, pluginManifest) {
if (!pluginManifest || !pluginManifest.entryPoints || !pluginManifest.entryPoints.settings) {
Logger.e("BarService", "Cannot open plugin settings: no settings entry point");
return;
}
// Get the popup menu window to use as parent
var popupMenuWindow = PanelService.getPopupMenuWindow(screen);
if (!popupMenuWindow) {
Logger.e("BarService", "No popup menu window found for screen");
return;
}
// Close any existing dialogs first to prevent stacking
closeExistingDialogs(popupMenuWindow);
var component = Qt.createComponent(Quickshell.shellDir + "/Widgets/NPluginSettingsPopup.qml");
function instantiateAndOpen() {
var dialog = component.createObject(popupMenuWindow.dialogParent, {
"showToastOnSave": true,
"screen": screen
});
if (dialog) {
// Enable keyboard focus for the popup menu window when dialog is open
popupMenuWindow.hasDialog = true;
// Close the popup menu window when dialog closes
dialog.closed.connect(() => {
popupMenuWindow.hasDialog = false;
popupMenuWindow.close();
dialog.destroy();
});
// Show the popup menu window and open the dialog
popupMenuWindow.open();
dialog.openPluginSettings(pluginManifest);
} else {
Logger.e("BarService", "Failed to create plugin settings dialog");
}
}
if (component.status === Component.Ready) {
instantiateAndOpen();
} else if (component.status === Component.Error) {
Logger.e("BarService", "Error loading plugin settings dialog:", component.errorString());
} else {
component.statusChanged.connect(function () {
if (component.status === Component.Ready) {
instantiateAndOpen();
} else if (component.status === Component.Error) {
Logger.e("BarService", "Error loading plugin settings dialog:", component.errorString());
}
});
}
}
}
@@ -0,0 +1,503 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Commons
import qs.Modules.Bar.Widgets
Singleton {
id: root
signal pluginWidgetRegistryUpdated
// Widget registry object mapping widget names to components
property var widgets: ({
"ActiveWindow": activeWindowComponent,
"AudioVisualizer": audioVisualizerComponent,
"Battery": batteryComponent,
"Bluetooth": bluetoothComponent,
"Brightness": brightnessComponent,
"Clock": clockComponent,
"ControlCenter": controlCenterComponent,
"CustomButton": customButtonComponent,
"DarkMode": darkModeComponent,
"KeepAwake": keepAwakeComponent,
"KeyboardLayout": keyboardLayoutComponent,
"LockKeys": lockKeysComponent,
"Launcher": launcherComponent,
"MediaMini": mediaMiniComponent,
"Microphone": microphoneComponent,
"Network": networkComponent,
"NightLight": nightLightComponent,
"NoctaliaPerformance": noctaliaPerformanceComponent,
"NotificationHistory": notificationHistoryComponent,
"PowerProfile": powerProfileComponent,
"SessionMenu": sessionMenuComponent,
"Settings": settingsComponent,
"Spacer": spacerComponent,
"SystemMonitor": systemMonitorComponent,
"Taskbar": taskbarComponent,
"Tray": trayComponent,
"Volume": volumeComponent,
"VPN": vpnComponent,
"WallpaperSelector": wallpaperSelectorComponent,
"Workspace": workspaceComponent
})
property var widgetSettingsMap: ({
"ActiveWindow": "WidgetSettings/ActiveWindowSettings.qml",
"AudioVisualizer": "WidgetSettings/AudioVisualizerSettings.qml",
"Battery": "WidgetSettings/BatterySettings.qml",
"Bluetooth": "WidgetSettings/BluetoothSettings.qml",
"Brightness": "WidgetSettings/BrightnessSettings.qml",
"Clock": "WidgetSettings/ClockSettings.qml",
"ControlCenter": "WidgetSettings/ControlCenterSettings.qml",
"CustomButton": "WidgetSettings/CustomButtonSettings.qml",
"DarkMode": "WidgetSettings/DarkModeSettings.qml",
"KeepAwake": "WidgetSettings/KeepAwakeSettings.qml",
"KeyboardLayout": "WidgetSettings/KeyboardLayoutSettings.qml",
"Launcher": "WidgetSettings/LauncherSettings.qml",
"LockKeys": "WidgetSettings/LockKeysSettings.qml",
"MediaMini": "WidgetSettings/MediaMiniSettings.qml",
"Microphone": "WidgetSettings/MicrophoneSettings.qml",
"Network": "WidgetSettings/NetworkSettings.qml",
"NightLight": "WidgetSettings/NightLightSettings.qml",
"NoctaliaPerformance": "WidgetSettings/NoctaliaPerformanceSettings.qml",
"NotificationHistory": "WidgetSettings/NotificationHistorySettings.qml",
"PowerProfile": "WidgetSettings/PowerProfileSettings.qml",
"SessionMenu": "WidgetSettings/SessionMenuSettings.qml",
"Settings": "WidgetSettings/SettingsSettings.qml",
"Spacer": "WidgetSettings/SpacerSettings.qml",
"SystemMonitor": "WidgetSettings/SystemMonitorSettings.qml",
"Taskbar": "WidgetSettings/TaskbarSettings.qml",
"Tray": "WidgetSettings/TraySettings.qml",
"Volume": "WidgetSettings/VolumeSettings.qml",
"VPN": "WidgetSettings/VPNSettings.qml",
"WallpaperSelector": "WidgetSettings/WallpaperSelectorSettings.qml",
"Workspace": "WidgetSettings/WorkspaceSettings.qml"
})
property var widgetMetadata: ({
"ActiveWindow": {
"showText": true,
"showIcon": true,
"hideMode": "hidden",
"scrollingMode": "hover",
"maxWidth": 145,
"useFixedWidth": false,
"colorizeIcons": false,
"textColor": "none"
},
"AudioVisualizer": {
"width": 200,
"colorName": "primary",
"hideWhenIdle": false
},
"Battery": {
"displayMode": "graphic-clean",
"deviceNativePath": "__default__",
"showPowerProfiles": false,
"showNoctaliaPerformance": false,
"hideIfNotDetected": true,
"hideIfIdle": false
},
"Bluetooth": {
"displayMode": "onhover",
"iconColor": "none",
"textColor": "none"
},
"Brightness": {
"displayMode": "onhover",
"iconColor": "none",
"textColor": "none",
"applyToAllMonitors": false
},
"Clock": {
"clockColor": "none",
"useCustomFont": false,
"customFont": "",
"formatHorizontal": "HH:mm ddd, MMM dd",
"formatVertical": "HH mm - dd MM",
"tooltipFormat": "HH:mm ddd, MMM dd"
},
"ControlCenter": {
"useDistroLogo": false,
"icon": "noctalia",
"customIconPath": "",
"colorizeDistroLogo": false,
"colorizeSystemIcon": "none",
"enableColorization": false
},
"CustomButton": {
"icon": "heart",
"showIcon": true,
"showExecTooltip": true,
"showTextTooltip": true,
"generalTooltipText": "",
"hideMode": "alwaysExpanded",
"leftClickExec": "",
"leftClickUpdateText": false,
"rightClickExec": "",
"rightClickUpdateText": false,
"middleClickExec": "",
"middleClickUpdateText": false,
"textCommand": "",
"textStream": false,
"textIntervalMs": 3000,
"textCollapse": "",
"parseJson": false,
"wheelExec": "",
"wheelUpExec": "",
"wheelDownExec": "",
"wheelMode": "unified",
"wheelUpdateText": false,
"wheelUpUpdateText": false,
"wheelDownUpdateText": false,
"maxTextLength": {
"horizontal": 10,
"vertical": 10
},
"enableColorization": false,
"colorizeSystemIcon": "none",
"ipcIdentifier": ""
},
"DarkMode": {
"iconColor": "none"
},
"KeepAwake": {
"iconColor": "none",
"textColor": "none"
},
"KeyboardLayout": {
"displayMode": "onhover",
"showIcon": true,
"iconColor": "none",
"textColor": "none"
},
"LockKeys": {
"showCapsLock": true,
"showNumLock": true,
"showScrollLock": true,
"capsLockIcon": "letter-c",
"numLockIcon": "letter-n",
"scrollLockIcon": "letter-s",
"hideWhenOff": false
},
"Launcher": {
"useDistroLogo": false,
"icon": "rocket",
"customIconPath": "",
"colorizeSystemIcon": "none",
"enableColorization": false,
"iconColor": "none"
},
"MediaMini": {
"hideMode": "hidden",
"scrollingMode": "hover",
"maxWidth": 145,
"useFixedWidth": false,
"hideWhenIdle": false,
"showAlbumArt": true,
"showArtistFirst": true,
"showVisualizer": false,
"showProgressRing": true,
"visualizerType": "linear",
"textColor": "none",
"compactMode": false,
"panelShowAlbumArt": true
},
"Microphone": {
"displayMode": "onhover",
"middleClickCommand": "pwvucontrol || pavucontrol",
"iconColor": "none",
"textColor": "none"
},
"NotificationHistory": {
"showUnreadBadge": true,
"hideWhenZero": false,
"hideWhenZeroUnread": false,
"unreadBadgeColor": "primary",
"iconColor": "none"
},
"SessionMenu": {
"iconColor": "error"
},
"Settings": {
"iconColor": "none"
},
"Spacer": {
"width": 20
},
"SystemMonitor": {
"compactMode": true,
"iconColor": "none",
"textColor": "none",
"useMonospaceFont": true,
"usePadding": false,
"showCpuUsage": true,
"showCpuCores": false,
"showCpuFreq": false,
"showCpuTemp": true,
"showGpuTemp": false,
"showLoadAverage": false,
"showMemoryUsage": true,
"showMemoryAsPercent": false,
"showSwapUsage": false,
"showNetworkStats": false,
"showDiskUsage": false,
"showDiskUsageAsPercent": false,
"showDiskAvailable": false,
"diskPath": "/"
},
"Taskbar": {
"onlySameOutput": true,
"onlyActiveWorkspaces": true,
"hideMode": "hidden",
"colorizeIcons": false,
"showTitle": false,
"titleWidth": 120,
"showPinnedApps": true,
"smartWidth": true,
"maxTaskbarWidth": 40,
"iconScale": 0.8
},
"Tray": {
"blacklist": [],
"colorizeIcons": false,
"chevronColor": "none",
"pinned": [],
"drawerEnabled": true,
"hidePassive": false
},
"VPN": {
"displayMode": "onhover",
"iconColor": "none",
"textColor": "none"
},
"Network": {
"displayMode": "onhover",
"iconColor": "none",
"textColor": "none"
},
"NightLight": {
"iconColor": "none"
},
"NoctaliaPerformance": {
"iconColor": "none"
},
"PowerProfile": {
"iconColor": "none"
},
"Workspace": {
"labelMode": "index",
"followFocusedScreen": false,
"hideUnoccupied": false,
"characterCount": 2,
"showApplications": false,
"showApplicationsHover": false,
"showLabelsOnlyWhenOccupied": true,
"colorizeIcons": false,
"unfocusedIconsOpacity": 1.0,
"groupedBorderOpacity": 1.0,
"enableScrollWheel": true,
"iconScale": 0.8,
"focusedColor": "primary",
"occupiedColor": "secondary",
"emptyColor": "secondary",
"showBadge": true,
"pillSize": 0.6,
"fontWeight": "bold"
},
"Volume": {
"displayMode": "onhover",
"middleClickCommand": "pwvucontrol || pavucontrol",
"iconColor": "none",
"textColor": "none"
},
"WallpaperSelector": {
"iconColor": "none"
}
})
// Component definitions - these are loaded once at startup
property Component activeWindowComponent: Component {
ActiveWindow {}
}
property Component audioVisualizerComponent: Component {
AudioVisualizer {}
}
property Component batteryComponent: Component {
Battery {}
}
property Component bluetoothComponent: Component {
Bluetooth {}
}
property Component brightnessComponent: Component {
Brightness {}
}
property Component clockComponent: Component {
Clock {}
}
property Component customButtonComponent: Component {
CustomButton {}
}
property Component darkModeComponent: Component {
DarkMode {}
}
property Component keyboardLayoutComponent: Component {
KeyboardLayout {}
}
property Component keepAwakeComponent: Component {
KeepAwake {}
}
property Component lockKeysComponent: Component {
LockKeys {}
}
property Component launcherComponent: Component {
Launcher {}
}
property Component mediaMiniComponent: Component {
MediaMini {}
}
property Component microphoneComponent: Component {
Microphone {}
}
property Component nightLightComponent: Component {
NightLight {}
}
property Component noctaliaPerformanceComponent: Component {
NoctaliaPerformance {}
}
property Component notificationHistoryComponent: Component {
NotificationHistory {}
}
property Component powerProfileComponent: Component {
PowerProfile {}
}
property Component sessionMenuComponent: Component {
SessionMenu {}
}
property Component settingsComponent: Component {
Settings {}
}
property Component controlCenterComponent: Component {
ControlCenter {}
}
property Component spacerComponent: Component {
Spacer {}
}
property Component systemMonitorComponent: Component {
SystemMonitor {}
}
property Component trayComponent: Component {
Tray {}
}
property Component volumeComponent: Component {
Volume {}
}
property Component vpnComponent: Component {
VPN {}
}
property Component networkComponent: Component {
Network {}
}
property Component wallpaperSelectorComponent: Component {
WallpaperSelector {}
}
property Component workspaceComponent: Component {
Workspace {}
}
property Component taskbarComponent: Component {
Taskbar {}
}
function init() {
Logger.i("BarWidgetRegistry", "Service started");
}
// ------------------------------
// Helper function to get widget component by name
function getWidget(id) {
return widgets[id] || null;
}
// Helper function to check if widget exists
function hasWidget(id) {
return id in widgets;
}
// Get list of available widget id
function getAvailableWidgets() {
return Object.keys(widgets);
}
// Helper function to check if widget has user settings
function widgetHasUserSettings(id) {
return widgetMetadata[id] !== undefined;
}
// ------------------------------
// Plugin widget registration
// Track plugin widgets separately
property var pluginWidgets: ({})
property var pluginWidgetMetadata: ({})
// Register a plugin widget
function registerPluginWidget(pluginId, component, metadata) {
if (!pluginId || !component) {
Logger.e("BarWidgetRegistry", "Cannot register plugin widget: invalid parameters");
return false;
}
// Add plugin: prefix to avoid conflicts with core widgets
var widgetId = "plugin:" + pluginId;
pluginWidgets[widgetId] = component;
pluginWidgetMetadata[widgetId] = metadata || {};
// Also add to main widgets object for unified access
widgets[widgetId] = component;
widgetMetadata[widgetId] = metadata || {};
Logger.i("BarWidgetRegistry", "Registered plugin widget:", widgetId);
root.pluginWidgetRegistryUpdated();
return true;
}
// Unregister a plugin widget
function unregisterPluginWidget(pluginId) {
var widgetId = "plugin:" + pluginId;
if (!pluginWidgets[widgetId]) {
Logger.w("BarWidgetRegistry", "Plugin widget not registered:", widgetId);
return false;
}
delete pluginWidgets[widgetId];
delete pluginWidgetMetadata[widgetId];
delete widgets[widgetId];
delete widgetMetadata[widgetId];
Logger.i("BarWidgetRegistry", "Unregistered plugin widget:", widgetId);
root.pluginWidgetRegistryUpdated();
return true;
}
// Check if a widget is a plugin widget
function isPluginWidget(id) {
return id.startsWith("plugin:");
}
property var cpuIntensiveWidgets: ["AudioVisualizer"]
function isCpuIntensive(id) {
if (pluginWidgetMetadata[id]?.cpuIntensive)
return true;
return cpuIntensiveWidgets.indexOf(id) >= 0;
}
// Get list of plugin widget IDs
function getPluginWidgets() {
return Object.keys(pluginWidgets);
}
}
@@ -0,0 +1,163 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Commons
import qs.Modules.Panels.ControlCenter.Widgets
Singleton {
id: root
// Widget registry object mapping widget names to components
property var widgets: ({
"AirplaneMode": airplaneModeComponent,
"Bluetooth": bluetoothComponent,
"CustomButton": customButtonComponent,
"DarkMode": darkModeComponent,
"KeepAwake": keepAwakeComponent,
"NightLight": nightLightComponent,
"Notifications": notificationsComponent,
"PowerProfile": powerProfileComponent,
"WiFi": networkComponent,
"Network": networkComponent,
"NoctaliaPerformance": noctaliaPerformanceComponent,
"WallpaperSelector": wallpaperSelectorComponent
})
property var widgetMetadata: ({
"CustomButton": {
"icon": "heart",
"onClicked": "",
"onRightClicked": "",
"onMiddleClicked": "",
"stateChecksJson": "[]",
"generalTooltipText": "",
"enableOnStateLogic": false,
"showExecTooltip": true
}
})
property var cpuIntensiveWidgets: ["SystemStat"]
// Component definitions - these are loaded once at startup
property Component airplaneModeComponent: Component {
AirplaneMode {}
}
property Component bluetoothComponent: Component {
Bluetooth {}
}
property Component customButtonComponent: Component {
CustomButton {}
}
property Component darkModeComponent: Component {
DarkMode {}
}
property Component keepAwakeComponent: Component {
KeepAwake {}
}
property Component nightLightComponent: Component {
NightLight {}
}
property Component notificationsComponent: Component {
Notifications {}
}
property Component powerProfileComponent: Component {
PowerProfile {}
}
property Component networkComponent: Component {
Network {}
}
property Component noctaliaPerformanceComponent: Component {
NoctaliaPerformance {}
}
property Component wallpaperSelectorComponent: Component {
WallpaperSelector {}
}
function init() {
Logger.i("ControlCenterWidgetRegistry", "Service started");
}
// ------------------------------
// Helper function to get widget component by name
function getWidget(id) {
return widgets[id] || null;
}
// Helper function to check if widget exists
function hasWidget(id) {
return id in widgets;
}
// Get list of available widget id
function getAvailableWidgets() {
return Object.keys(widgets);
}
// Helper function to check if widget has user settings
function widgetHasUserSettings(id) {
return widgetMetadata[id] !== undefined;
}
// ------------------------------
// Plugin widget registration
// Track plugin widgets separately
property var pluginWidgets: ({})
property var pluginWidgetMetadata: ({})
// Register a plugin widget
function registerPluginWidget(pluginId, component, metadata) {
if (!pluginId || !component) {
Logger.e("ControlCenterWidgetRegistry", "Cannot register plugin widget: invalid parameters");
return false;
}
// Add plugin: prefix to avoid conflicts with core widgets
var widgetId = "plugin:" + pluginId;
pluginWidgets[widgetId] = component;
pluginWidgetMetadata[widgetId] = metadata || {};
// Also add to main widgets object for unified access
widgets[widgetId] = component;
widgetMetadata[widgetId] = metadata || {};
Logger.i("ControlCenterWidgetRegistry", "Registered plugin widget:", widgetId);
return true;
}
// Unregister a plugin widget
function unregisterPluginWidget(pluginId) {
var widgetId = "plugin:" + pluginId;
if (!pluginWidgets[widgetId]) {
Logger.w("ControlCenterWidgetRegistry", "Plugin widget not registered:", widgetId);
return false;
}
delete pluginWidgets[widgetId];
delete pluginWidgetMetadata[widgetId];
delete widgets[widgetId];
delete widgetMetadata[widgetId];
Logger.i("ControlCenterWidgetRegistry", "Unregistered plugin widget:", widgetId);
return true;
}
// Check if a widget is a plugin widget
function isPluginWidget(id) {
return id.startsWith("plugin:");
}
// Get list of plugin widget IDs
function getPluginWidgets() {
return Object.keys(pluginWidgets);
}
function isCpuIntensive(id) {
if (pluginWidgetMetadata[id]?.cpuIntensive)
return true;
return cpuIntensiveWidgets.indexOf(id) >= 0;
}
}
@@ -0,0 +1,346 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Commons
import qs.Modules.DesktopWidgets.Widgets
import qs.Services.Noctalia
Singleton {
id: root
// Transient state - not persisted, resets on shell restart
property bool editMode: false
// Signal emitted when plugin widgets are registered/unregistered
signal pluginWidgetRegistryUpdated
// Component definitions
property Component clockComponent: Component {
DesktopClock {}
}
property Component mediaPlayerComponent: Component {
DesktopMediaPlayer {}
}
property Component weatherComponent: Component {
DesktopWeather {}
}
property Component systemStatComponent: Component {
DesktopSystemStat {}
}
property Component audioVisualizerComponent: Component {
DesktopAudioVisualizer {}
}
// Widget registry object mapping widget names to components
// Created in Component.onCompleted to ensure Components are ready
property var widgets: ({})
// Explicit URLs for core widgets. Inline Component.url returns the parent
// file's URL, so we need these for Loader.setSource() with initial properties.
// Plugin widgets use their Component.url directly (loaded from actual files).
readonly property string _widgetsDir: Quickshell.shellDir + "/Modules/DesktopWidgets/Widgets/"
property var widgetUrls: ({})
Component.onCompleted: {
// Initialize widgets object after Components are ready
var widgetsObj = {};
widgetsObj["Clock"] = clockComponent;
widgetsObj["MediaPlayer"] = mediaPlayerComponent;
widgetsObj["Weather"] = weatherComponent;
widgetsObj["SystemStat"] = systemStatComponent;
widgetsObj["AudioVisualizer"] = audioVisualizerComponent;
widgets = widgetsObj;
var urlsObj = {};
urlsObj["Clock"] = _widgetsDir + "DesktopClock.qml";
urlsObj["MediaPlayer"] = _widgetsDir + "DesktopMediaPlayer.qml";
urlsObj["Weather"] = _widgetsDir + "DesktopWeather.qml";
urlsObj["SystemStat"] = _widgetsDir + "DesktopSystemStat.qml";
urlsObj["AudioVisualizer"] = _widgetsDir + "DesktopAudioVisualizer.qml";
widgetUrls = urlsObj;
Logger.i("DesktopWidgetRegistry", "Service started");
}
property var widgetSettingsMap: ({
"Clock": "WidgetSettings/ClockSettings.qml",
"MediaPlayer": "WidgetSettings/MediaPlayerSettings.qml",
"Weather": "WidgetSettings/WeatherSettings.qml",
"SystemStat": "WidgetSettings/SystemStatSettings.qml",
"AudioVisualizer": "WidgetSettings/AudioVisualizerSettings.qml"
})
property var widgetMetadata: ({
"Clock": {
"showBackground": true,
"roundedCorners": true,
"clockStyle": "digital",
"clockColor": "none",
"useCustomFont": false,
"customFont": "",
"format": "HH:mm\\nd MMMM yyyy"
},
"MediaPlayer": {
"showBackground": true,
"roundedCorners": true,
"visualizerType": "linear",
"hideMode": "visible",
"showButtons": true,
"showAlbumArt": true,
"showVisualizer": true
},
"Weather": {
"showBackground": true,
"roundedCorners": true
},
"SystemStat": {
"showBackground": true,
"roundedCorners": true,
"statType": "CPU",
"diskPath": "/",
"layout": "bottom"
},
"AudioVisualizer": {
"showBackground": true,
"roundedCorners": true,
"width": 320,
"height": 72,
"visualizerType": "linear",
"hideWhenIdle": false,
"colorName": "primary"
}
})
property var cpuIntensiveWidgets: ["SystemStat", "AudioVisualizer"]
// Plugin widget storage (mirroring BarWidgetRegistry pattern)
property var pluginWidgets: ({})
property var pluginWidgetMetadata: ({})
function init() {
Logger.i("DesktopWidgetRegistry", "Service started");
}
// Helper function to get widget component by name
function getWidget(id) {
return widgets[id] || null;
}
// Helper function to check if widget exists
function hasWidget(id) {
return id in widgets;
}
// Get list of available widget ids
function getAvailableWidgets() {
var keys = Object.keys(widgets);
Logger.d("DesktopWidgetRegistry", "getAvailableWidgets() called, returning:", keys);
return keys;
}
// Helper function to check if widget has user settings
function widgetHasUserSettings(id) {
return widgetMetadata[id] !== undefined;
}
function isCpuIntensive(id) {
if (pluginWidgetMetadata[id]?.cpuIntensive)
return true;
return cpuIntensiveWidgets.indexOf(id) >= 0;
}
// Check if a widget is a plugin widget
function isPluginWidget(id) {
return id.startsWith("plugin:");
}
// Get list of plugin widget IDs
function getPluginWidgets() {
return Object.keys(pluginWidgets);
}
// Get display name for a widget ID
function getWidgetDisplayName(widgetId) {
if (widgetId.startsWith("plugin:")) {
var pluginId = widgetId.replace("plugin:", "");
var manifest = PluginRegistry.getPluginManifest(pluginId);
return manifest ? manifest.name : pluginId;
}
// Core widgets - return as-is (Clock, MediaPlayer, Weather, SystemStat)
return widgetId;
}
// Register a plugin desktop widget
function registerPluginWidget(pluginId, component, metadata) {
if (!pluginId || !component) {
Logger.e("DesktopWidgetRegistry", "Cannot register plugin widget: invalid parameters");
return false;
}
var widgetId = "plugin:" + pluginId;
// Create new objects to trigger QML property change detection
var newPluginWidgets = Object.assign({}, pluginWidgets);
newPluginWidgets[widgetId] = component;
pluginWidgets = newPluginWidgets;
var newPluginMetadata = Object.assign({}, pluginWidgetMetadata);
newPluginMetadata[widgetId] = metadata || {};
pluginWidgetMetadata = newPluginMetadata;
// Also add to main widgets object for unified access - reassign to trigger change
var newWidgets = Object.assign({}, widgets);
newWidgets[widgetId] = component;
widgets = newWidgets;
var newMetadata = Object.assign({}, widgetMetadata);
newMetadata[widgetId] = Object.assign({}, {
"showBackground": true
}, metadata || {});
widgetMetadata = newMetadata;
Logger.i("DesktopWidgetRegistry", "Registered plugin widget:", widgetId);
root.pluginWidgetRegistryUpdated();
return true;
}
// Unregister a plugin desktop widget
function unregisterPluginWidget(pluginId) {
var widgetId = "plugin:" + pluginId;
if (!pluginWidgets[widgetId]) {
Logger.w("DesktopWidgetRegistry", "Plugin widget not registered:", widgetId);
return false;
}
// Create new objects without the widget to trigger QML property change detection
var newPluginWidgets = Object.assign({}, pluginWidgets);
delete newPluginWidgets[widgetId];
pluginWidgets = newPluginWidgets;
var newPluginMetadata = Object.assign({}, pluginWidgetMetadata);
delete newPluginMetadata[widgetId];
pluginWidgetMetadata = newPluginMetadata;
var newWidgets = Object.assign({}, widgets);
delete newWidgets[widgetId];
widgets = newWidgets;
var newMetadata = Object.assign({}, widgetMetadata);
delete newMetadata[widgetId];
widgetMetadata = newMetadata;
Logger.i("DesktopWidgetRegistry", "Unregistered plugin widget:", widgetId);
root.pluginWidgetRegistryUpdated();
return true;
}
function updateWidgetData(monitorName, widgetIndex, properties) {
if (widgetIndex < 0 || !monitorName) {
return;
}
var monitorWidgets = Settings.data.desktopWidgets.monitorWidgets || [];
var newMonitorWidgets = monitorWidgets.slice();
for (var i = 0; i < newMonitorWidgets.length; i++) {
if (newMonitorWidgets[i].name === monitorName) {
var widgets = (newMonitorWidgets[i].widgets || []).slice();
if (widgetIndex < widgets.length) {
widgets[widgetIndex] = Object.assign({}, widgets[widgetIndex], properties);
newMonitorWidgets[i] = Object.assign({}, newMonitorWidgets[i], {
"widgets": widgets
});
Settings.data.desktopWidgets.monitorWidgets = newMonitorWidgets;
}
break;
}
}
}
property var currentSettingsDialog: null
function openWidgetSettings(screen, widgetIndex, widgetId, widgetData) {
if (!widgetId || !screen) {
return;
}
if (root.currentSettingsDialog) {
root.currentSettingsDialog.close();
root.currentSettingsDialog.destroy();
root.currentSettingsDialog = null;
}
var hasSettings = false;
if (root.isPluginWidget(widgetId)) {
var pluginId = widgetId.replace("plugin:", "");
var manifest = PluginRegistry.getPluginManifest(pluginId);
if (manifest && manifest.entryPoints && (manifest.entryPoints.desktopWidgetSettings || manifest.entryPoints.settings)) {
hasSettings = true;
}
} else {
hasSettings = root.widgetSettingsMap[widgetId] !== undefined;
}
if (!hasSettings) {
Logger.w("DesktopWidgetRegistry", "Widget does not have settings:", widgetId);
return;
}
var popupMenuWindow = PanelService.getPopupMenuWindow(screen);
if (!popupMenuWindow) {
Logger.e("DesktopWidgetRegistry", "No popup menu window found for screen");
return;
}
if (popupMenuWindow.hideDynamicMenu) {
popupMenuWindow.hideDynamicMenu();
}
var component = Qt.createComponent(Quickshell.shellDir + "/Modules/Panels/Settings/DesktopWidgets/DesktopWidgetSettingsDialog.qml");
function instantiateAndOpen() {
var dialog = component.createObject(popupMenuWindow.dialogParent, {
"widgetIndex": widgetIndex,
"widgetData": widgetData,
"widgetId": widgetId,
"sectionId": screen.name,
"screen": screen
});
if (dialog) {
root.currentSettingsDialog = dialog;
dialog.updateWidgetSettings.connect((sec, idx, settings) => {
root.updateWidgetData(sec, idx, settings);
});
popupMenuWindow.hasDialog = true;
dialog.closed.connect(() => {
popupMenuWindow.hasDialog = false;
popupMenuWindow.close();
if (root.currentSettingsDialog === dialog) {
root.currentSettingsDialog = null;
}
dialog.destroy();
});
dialog.open();
} else {
Logger.e("DesktopWidgetRegistry", "Failed to create widget settings dialog");
}
}
if (component.status === Component.Ready) {
instantiateAndOpen();
} else if (component.status === Component.Error) {
Logger.e("DesktopWidgetRegistry", "Error loading settings dialog component:", component.errorString());
} else {
component.statusChanged.connect(() => {
if (component.status === Component.Ready) {
instantiateAndOpen();
} else if (component.status === Component.Error) {
Logger.e("DesktopWidgetRegistry", "Error loading settings dialog component:", component.errorString());
}
});
}
}
}
@@ -0,0 +1,784 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import "../../Helpers/sha256.js" as Checksum
import qs.Commons
Singleton {
id: root
// -------------------------------------------------
// Public Properties
// -------------------------------------------------
property bool imageMagickAvailable: false
property bool initialized: false
// Cache directories
readonly property string baseDir: Settings.cacheDir + "images/"
readonly property string wpThumbDir: baseDir + "wallpapers/thumbnails/"
readonly property string wpLargeDir: baseDir + "wallpapers/large/"
readonly property string notificationsDir: baseDir + "notifications/"
readonly property string contributorsDir: baseDir + "contributors/"
// Supported image formats - extended list when ImageMagick is available
readonly property var basicImageFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.bmp"]
readonly property var extendedImageFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.bmp", "*.webp", "*.avif", "*.heic", "*.heif", "*.tiff", "*.tif", "*.pnm", "*.pgm", "*.ppm", "*.pbm", "*.svg", "*.svgz", "*.ico", "*.icns", "*.jxl", "*.jp2", "*.j2k", "*.exr", "*.hdr", "*.dds", "*.tga"]
readonly property var imageFilters: imageMagickAvailable ? extendedImageFilters : basicImageFilters
// Check if a file format needs conversion (not natively supported by Qt)
function needsConversion(filePath) {
const ext = "*." + filePath.toLowerCase().split('.').pop();
return !basicImageFilters.includes(ext);
}
// -------------------------------------------------
// Internal State
// -------------------------------------------------
property var pendingRequests: ({})
property var fallbackQueue: []
property bool fallbackProcessing: false
// Process queues to prevent "too many open files" errors
property var utilityProcessQueue: []
property int runningUtilityProcesses: 0
readonly property int maxConcurrentUtilityProcesses: 16
// Separate queue for heavy ImageMagick processing (lower concurrency)
property var imageMagickQueue: []
property int runningImageMagickProcesses: 0
readonly property int maxConcurrentImageMagickProcesses: 4
// -------------------------------------------------
// Signals
// -------------------------------------------------
signal cacheHit(string cacheKey, string cachedPath)
signal cacheMiss(string cacheKey)
signal processingComplete(string cacheKey, string cachedPath)
signal processingFailed(string cacheKey, string error)
// -------------------------------------------------
// Initialization
// -------------------------------------------------
function init() {
Logger.i("ImageCache", "Service started");
createDirectories();
cleanupOldCache();
checkMagickProcess.running = true;
}
function createDirectories() {
Quickshell.execDetached(["mkdir", "-p", wpThumbDir]);
Quickshell.execDetached(["mkdir", "-p", wpLargeDir]);
Quickshell.execDetached(["mkdir", "-p", notificationsDir]);
Quickshell.execDetached(["mkdir", "-p", contributorsDir]);
}
function cleanupOldCache() {
const dirs = [wpThumbDir, wpLargeDir, notificationsDir, contributorsDir];
dirs.forEach(function (dir) {
Quickshell.execDetached(["find", dir, "-type", "f", "-mtime", "+15", "-delete"]);
});
Logger.d("ImageCache", "Cleanup triggered for files older than 15 days");
}
// -------------------------------------------------
// Public API: Get Thumbnail (384x384)
// -------------------------------------------------
function getThumbnail(sourcePath, callback) {
if (!sourcePath || sourcePath === "") {
callback("", false);
return;
}
getMtime(sourcePath, function (mtime) {
const cacheKey = generateThumbnailKey(sourcePath, mtime);
const cachedPath = wpThumbDir + cacheKey + ".png";
processRequest(cacheKey, cachedPath, sourcePath, callback, function () {
if (imageMagickAvailable) {
startThumbnailProcessing(sourcePath, cachedPath, cacheKey);
} else {
queueFallbackProcessing(sourcePath, cachedPath, cacheKey, 384);
}
});
});
}
// -------------------------------------------------
// Public API: Get Large Image (scaled to specified dimensions)
// -------------------------------------------------
function getLarge(sourcePath, width, height, callback) {
if (!sourcePath || sourcePath === "") {
callback("", false);
return;
}
if (Settings.data.wallpaper.useOriginalImages && !needsConversion(sourcePath)) {
callback(sourcePath, false);
return;
}
if (!imageMagickAvailable) {
Logger.d("ImageCache", "ImageMagick not available, using original:", sourcePath);
callback(sourcePath, false);
return;
}
// Fast dimension check - skip processing if image fits screen AND format is Qt-native
getImageDimensions(sourcePath, function (imgWidth, imgHeight) {
const fitsScreen = imgWidth > 0 && imgHeight > 0 && imgWidth <= width && imgHeight <= height;
if (fitsScreen) {
// Only skip if format is natively supported by Qt
if (!needsConversion(sourcePath)) {
Logger.d("ImageCache", `Image ${imgWidth}x${imgHeight} fits screen ${width}x${height}, using original`);
callback(sourcePath, false);
return;
}
Logger.d("ImageCache", `Image needs conversion despite fitting screen`);
}
// Use actual image dimensions if it fits (convert without upscaling), otherwise use screen dimensions
const targetWidth = fitsScreen ? imgWidth : width;
const targetHeight = fitsScreen ? imgHeight : height;
getMtime(sourcePath, function (mtime) {
const cacheKey = generateLargeKey(sourcePath, width, height, mtime);
const cachedPath = wpLargeDir + cacheKey + ".png";
processRequest(cacheKey, cachedPath, sourcePath, callback, function () {
startLargeProcessing(sourcePath, cachedPath, targetWidth, targetHeight, cacheKey);
});
});
});
}
// -------------------------------------------------
// Public API: Get Notification Icon (64x64)
// -------------------------------------------------
function getNotificationIcon(imageUri, appName, summary, callback) {
if (!imageUri || imageUri === "") {
callback("", false);
return;
}
// Resolve bare file path for temp check
const filePath = imageUri.startsWith("file://") ? imageUri.substring(7) : imageUri;
// File paths in persistent locations are used directly, not cached
if ((imageUri.startsWith("/") || imageUri.startsWith("file://")) && !isTemporaryPath(filePath)) {
callback(imageUri, false);
return;
}
const cacheKey = generateNotificationKey(imageUri, appName, summary);
const cachedPath = notificationsDir + cacheKey + ".png";
// Temporary file paths are copied to cache before the source is cleaned up
if (imageUri.startsWith("/") || imageUri.startsWith("file://")) {
processRequest(cacheKey, cachedPath, imageUri, callback, function () {
copyTempFileToCache(filePath, cachedPath, cacheKey);
});
return;
}
processRequest(cacheKey, cachedPath, imageUri, callback, function () {
// Notifications always use Qt fallback (image:// URIs can't be read by ImageMagick)
queueFallbackProcessing(imageUri, cachedPath, cacheKey, 64);
});
}
// Check if a path is in a temporary directory that may be cleaned up
function isTemporaryPath(path) {
return path.startsWith("/tmp/");
}
// Copy a temporary file to the cache directory
function copyTempFileToCache(sourcePath, destPath, cacheKey) {
const srcEsc = sourcePath.replace(/'/g, "'\\''");
const dstEsc = destPath.replace(/'/g, "'\\''");
const processString = `
import QtQuick
import Quickshell.Io
Process {
command: ["cp", "--", "${srcEsc}", "${dstEsc}"]
stdout: StdioCollector {}
stderr: StdioCollector {}
}
`;
queueUtilityProcess({
name: "CopyTempFile_" + cacheKey,
processString: processString,
onComplete: function (exitCode) {
if (exitCode === 0) {
Logger.d("ImageCache", "Temp file cached:", destPath);
notifyCallbacks(cacheKey, destPath, true);
} else {
Logger.w("ImageCache", "Failed to cache temp file:", sourcePath);
notifyCallbacks(cacheKey, "", false);
}
},
onError: function () {
Logger.e("ImageCache", "Error caching temp file:", sourcePath);
notifyCallbacks(cacheKey, "", false);
}
});
}
// -------------------------------------------------
// Public API: Get Circular Avatar (256x256)
// -------------------------------------------------
function getCircularAvatar(url, username, callback) {
if (!url || !username) {
callback("", false);
return;
}
const cacheKey = username;
const cachedPath = contributorsDir + username + "_circular.png";
processRequest(cacheKey, cachedPath, url, callback, function () {
if (imageMagickAvailable) {
downloadAndProcessAvatar(url, username, cachedPath, cacheKey);
} else {
// No fallback for circular avatars without ImageMagick
Logger.w("ImageCache", "Circular avatars require ImageMagick");
notifyCallbacks(cacheKey, "", false);
}
});
}
// -------------------------------------------------
// Cache Key Generation
// -------------------------------------------------
function generateThumbnailKey(sourcePath, mtime) {
const keyString = sourcePath + "@384x384@" + (mtime || "unknown");
return Checksum.sha256(keyString);
}
function generateLargeKey(sourcePath, width, height, mtime) {
const keyString = sourcePath + "@" + width + "x" + height + "@" + (mtime || "unknown");
return Checksum.sha256(keyString);
}
function generateNotificationKey(imageUri, appName, summary) {
if (imageUri.startsWith("image://qsimage/")) {
return Checksum.sha256(appName + "|" + summary);
}
return Checksum.sha256(imageUri);
}
// -------------------------------------------------
// Request Processing (with coalescing)
// -------------------------------------------------
function processRequest(cacheKey, cachedPath, sourcePath, callback, processFn) {
// Check if already processing this request
if (pendingRequests[cacheKey]) {
pendingRequests[cacheKey].callbacks.push(callback);
Logger.d("ImageCache", "Coalescing request for:", cacheKey);
return;
}
// Check cache first
checkFileExists(cachedPath, function (exists) {
if (exists) {
Logger.d("ImageCache", "Cache hit:", cachedPath);
callback(cachedPath, true);
cacheHit(cacheKey, cachedPath);
return;
}
// Re-check pendingRequests (race condition fix)
if (pendingRequests[cacheKey]) {
pendingRequests[cacheKey].callbacks.push(callback);
return;
}
// Start new processing
Logger.d("ImageCache", "Cache miss, processing:", sourcePath);
cacheMiss(cacheKey);
pendingRequests[cacheKey] = {
callbacks: [callback],
sourcePath: sourcePath
};
processFn();
});
}
function notifyCallbacks(cacheKey, path, success) {
const request = pendingRequests[cacheKey];
if (request) {
request.callbacks.forEach(function (cb) {
cb(path, success);
});
delete pendingRequests[cacheKey];
}
if (success) {
processingComplete(cacheKey, path);
} else {
processingFailed(cacheKey, "Processing failed");
}
}
// -------------------------------------------------
// ImageMagick Processing: Thumbnail
// -------------------------------------------------
function startThumbnailProcessing(sourcePath, outputPath, cacheKey) {
const srcEsc = sourcePath.replace(/'/g, "'\\''");
const dstEsc = outputPath.replace(/'/g, "'\\''");
// Use Lanczos filter for high-quality downscaling, subtle unsharp mask, and PNG for lossless output
const command = `magick '${srcEsc}' -auto-orient -filter Lanczos -resize '384x384^' -gravity center -extent 384x384 -unsharp 0x0.5 '${dstEsc}'`;
runProcess(command, cacheKey, outputPath, sourcePath);
}
// -------------------------------------------------
// ImageMagick Processing: Large
// -------------------------------------------------
function startLargeProcessing(sourcePath, outputPath, width, height, cacheKey) {
const srcEsc = sourcePath.replace(/'/g, "'\\''");
const dstEsc = outputPath.replace(/'/g, "'\\''");
// Use Lanczos filter for high-quality downscaling and PNG for lossless output
const command = `magick '${srcEsc}' -auto-orient -filter Lanczos -resize '${width}x${height}^' '${dstEsc}'`;
runProcess(command, cacheKey, outputPath, sourcePath);
}
// -------------------------------------------------
// ImageMagick Processing: Circular Avatar
// -------------------------------------------------
function downloadAndProcessAvatar(url, username, outputPath, cacheKey) {
const tempPath = contributorsDir + username + "_temp.png";
const tempEsc = tempPath.replace(/'/g, "'\\''");
const urlEsc = url.replace(/'/g, "'\\''");
// Download first (uses utility queue since curl/wget are lightweight)
const downloadCmd = `curl -L -s -o '${tempEsc}' '${urlEsc}' || wget -q -O '${tempEsc}' '${urlEsc}'`;
const processString = `
import QtQuick
import Quickshell.Io
Process {
command: ["sh", "-c", "${downloadCmd.replace(/"/g, '\\"')}"]
stdout: StdioCollector {}
stderr: StdioCollector {}
}
`;
queueUtilityProcess({
name: "DownloadProcess_" + cacheKey,
processString: processString,
onComplete: function (exitCode) {
if (exitCode !== 0) {
Logger.e("ImageCache", "Failed to download avatar for", username);
notifyCallbacks(cacheKey, "", false);
return;
}
// Now process with ImageMagick
processCircularAvatar(tempPath, outputPath, cacheKey);
},
onError: function () {
notifyCallbacks(cacheKey, "", false);
}
});
}
function processCircularAvatar(inputPath, outputPath, cacheKey) {
const srcEsc = inputPath.replace(/'/g, "'\\''");
const dstEsc = outputPath.replace(/'/g, "'\\''");
// ImageMagick command for circular crop with alpha
const command = `magick '${srcEsc}' -resize 256x256^ -gravity center -extent 256x256 -alpha set \\( +clone -channel A -evaluate set 0 +channel -fill white -draw 'circle 128,128 128,0' \\) -compose DstIn -composite '${dstEsc}'`;
queueImageMagickProcess({
command: command,
cacheKey: cacheKey,
onComplete: function (exitCode) {
// Clean up temp file
Quickshell.execDetached(["rm", "-f", inputPath]);
if (exitCode !== 0) {
Logger.e("ImageCache", "Failed to create circular avatar");
notifyCallbacks(cacheKey, "", false);
} else {
Logger.d("ImageCache", "Circular avatar created:", outputPath);
notifyCallbacks(cacheKey, outputPath, true);
}
},
onError: function () {
Quickshell.execDetached(["rm", "-f", inputPath]);
notifyCallbacks(cacheKey, "", false);
}
});
}
// -------------------------------------------------
// Generic Process Runner (with queue for ImageMagick)
// -------------------------------------------------
// Queue an ImageMagick process and run it when a slot is available
function queueImageMagickProcess(request) {
imageMagickQueue.push(request);
processImageMagickQueue();
}
// Process queued ImageMagick requests up to the concurrency limit
function processImageMagickQueue() {
while (runningImageMagickProcesses < maxConcurrentImageMagickProcesses && imageMagickQueue.length > 0) {
const request = imageMagickQueue.shift();
runImageMagickProcess(request);
}
}
// Actually run an ImageMagick process
function runImageMagickProcess(request) {
runningImageMagickProcesses++;
const processString = `
import QtQuick
import Quickshell.Io
Process {
command: ["sh", "-c", ""]
stdout: StdioCollector {}
stderr: StdioCollector {}
}
`;
try {
const processObj = Qt.createQmlObject(processString, root, "ImageProcess_" + request.cacheKey);
processObj.command = ["sh", "-c", request.command];
processObj.exited.connect(function (exitCode) {
processObj.destroy();
runningImageMagickProcesses--;
request.onComplete(exitCode, processObj);
processImageMagickQueue();
});
processObj.running = true;
} catch (e) {
Logger.e("ImageCache", "Failed to create process:", e);
runningImageMagickProcesses--;
request.onError(e);
processImageMagickQueue();
}
}
function runProcess(command, cacheKey, outputPath, sourcePath) {
queueImageMagickProcess({
command: command,
cacheKey: cacheKey,
onComplete: function (exitCode, proc) {
if (exitCode !== 0) {
const stderrText = proc.stderr.text || "";
Logger.e("ImageCache", "Processing failed:", stderrText);
notifyCallbacks(cacheKey, sourcePath, false);
} else {
Logger.d("ImageCache", "Processing complete:", outputPath);
notifyCallbacks(cacheKey, outputPath, true);
}
},
onError: function () {
notifyCallbacks(cacheKey, sourcePath, false);
}
});
}
// -------------------------------------------------
// Qt Fallback Renderer
// -------------------------------------------------
PanelWindow {
id: fallbackRenderer
implicitWidth: 0
implicitHeight: 0
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "noctalia-image-cache-renderer"
color: "transparent"
mask: Region {}
Image {
id: fallbackImage
property string cacheKey: ""
property string destPath: ""
property int targetSize: 256
width: targetSize
height: targetSize
visible: true
cache: false
asynchronous: true
fillMode: Image.PreserveAspectCrop
mipmap: true
antialiasing: true
onStatusChanged: {
if (!cacheKey)
return;
if (status === Image.Ready) {
grabToImage(function (result) {
if (result.saveToFile(destPath)) {
Logger.d("ImageCache", "Fallback cache created:", destPath);
root.notifyCallbacks(cacheKey, destPath, true);
} else {
Logger.e("ImageCache", "Failed to save fallback cache");
root.notifyCallbacks(cacheKey, "", false);
}
processNextFallback();
});
} else if (status === Image.Error) {
Logger.e("ImageCache", "Fallback image load failed");
root.notifyCallbacks(cacheKey, "", false);
processNextFallback();
}
}
function processNextFallback() {
cacheKey = "";
destPath = "";
source = "";
if (fallbackQueue.length > 0) {
const next = fallbackQueue.shift();
cacheKey = next.cacheKey;
destPath = next.destPath;
targetSize = next.size;
source = next.sourcePath;
} else {
fallbackProcessing = false;
}
}
}
}
function queueFallbackProcessing(sourcePath, destPath, cacheKey, size) {
fallbackQueue.push({
sourcePath: sourcePath,
destPath: destPath,
cacheKey: cacheKey,
size: size
});
if (!fallbackProcessing) {
fallbackProcessing = true;
const item = fallbackQueue.shift();
fallbackImage.cacheKey = item.cacheKey;
fallbackImage.destPath = item.destPath;
fallbackImage.targetSize = item.size;
fallbackImage.source = item.sourcePath;
}
}
// -------------------------------------------------
// Utility Functions (with process queue to prevent fd exhaustion)
// -------------------------------------------------
// Queue a utility process and run it when a slot is available
function queueUtilityProcess(request) {
utilityProcessQueue.push(request);
processUtilityQueue();
}
// Process queued utility requests up to the concurrency limit
function processUtilityQueue() {
while (runningUtilityProcesses < maxConcurrentUtilityProcesses && utilityProcessQueue.length > 0) {
const request = utilityProcessQueue.shift();
runUtilityProcess(request);
}
}
// Actually run a utility process
function runUtilityProcess(request) {
runningUtilityProcesses++;
try {
const processObj = Qt.createQmlObject(request.processString, root, request.name);
processObj.exited.connect(function (exitCode) {
processObj.destroy();
runningUtilityProcesses--;
request.onComplete(exitCode, processObj);
processUtilityQueue();
});
processObj.running = true;
} catch (e) {
Logger.e("ImageCache", "Failed to create " + request.name + ":", e);
runningUtilityProcesses--;
request.onError(e);
processUtilityQueue();
}
}
function getMtime(filePath, callback) {
const pathEsc = filePath.replace(/'/g, "'\\''");
const processString = `
import QtQuick
import Quickshell.Io
Process {
command: ["stat", "-c", "%Y", "${pathEsc}"]
stdout: StdioCollector {}
stderr: StdioCollector {}
}
`;
queueUtilityProcess({
name: "MtimeProcess",
processString: processString,
onComplete: function (exitCode, proc) {
const mtime = exitCode === 0 ? proc.stdout.text.trim() : "";
callback(mtime);
},
onError: function () {
callback("");
}
});
}
function checkFileExists(filePath, callback) {
const pathEsc = filePath.replace(/'/g, "'\\''");
const processString = `
import QtQuick
import Quickshell.Io
Process {
command: ["test", "-f", "${pathEsc}"]
stdout: StdioCollector {}
stderr: StdioCollector {}
}
`;
queueUtilityProcess({
name: "FileExistsProcess",
processString: processString,
onComplete: function (exitCode) {
callback(exitCode === 0);
},
onError: function () {
callback(false);
}
});
}
function getImageDimensions(filePath, callback) {
const pathEsc = filePath.replace(/'/g, "'\\''");
const processString = `
import QtQuick
import Quickshell.Io
Process {
command: ["identify", "-ping", "-format", "%w %h", "${pathEsc}[0]"]
stdout: StdioCollector {}
stderr: StdioCollector {}
}
`;
queueUtilityProcess({
name: "IdentifyProcess",
processString: processString,
onComplete: function (exitCode, proc) {
let width = 0, height = 0;
if (exitCode === 0) {
const parts = proc.stdout.text.trim().split(" ");
if (parts.length >= 2) {
width = parseInt(parts[0], 10) || 0;
height = parseInt(parts[1], 10) || 0;
}
}
callback(width, height);
},
onError: function () {
callback(0, 0);
}
});
}
// -------------------------------------------------
// Cache Invalidation
// -------------------------------------------------
function invalidateThumbnail(sourcePath) {
Logger.i("ImageCache", "Invalidating thumbnail for:", sourcePath);
// Since cache keys include hash, we'd need to track mappings
// For simplicity, clear all thumbnails
clearThumbnails();
}
function invalidateLarge(sourcePath) {
Logger.i("ImageCache", "Invalidating large for:", sourcePath);
clearLarge();
}
function invalidateNotification(imageId) {
const path = notificationsDir + imageId + ".png";
Quickshell.execDetached(["rm", "-f", path]);
}
function invalidateAvatar(username) {
const path = contributorsDir + username + "_circular.png";
Quickshell.execDetached(["rm", "-f", path]);
}
// -------------------------------------------------
// Clear Cache Functions
// -------------------------------------------------
function clearAll() {
Logger.i("ImageCache", "Clearing all cache");
clearThumbnails();
clearLarge();
clearNotifications();
clearContributors();
}
function clearThumbnails() {
Logger.i("ImageCache", "Clearing thumbnails cache");
Quickshell.execDetached(["rm", "-rf", wpThumbDir]);
Quickshell.execDetached(["mkdir", "-p", wpThumbDir]);
}
function clearLarge() {
Logger.i("ImageCache", "Clearing large cache");
Quickshell.execDetached(["rm", "-rf", wpLargeDir]);
Quickshell.execDetached(["mkdir", "-p", wpLargeDir]);
}
function clearNotifications() {
Logger.i("ImageCache", "Clearing notifications cache");
Quickshell.execDetached(["rm", "-rf", notificationsDir]);
Quickshell.execDetached(["mkdir", "-p", notificationsDir]);
}
function clearContributors() {
Logger.i("ImageCache", "Clearing contributors cache");
Quickshell.execDetached(["rm", "-rf", contributorsDir]);
Quickshell.execDetached(["mkdir", "-p", contributorsDir]);
}
// -------------------------------------------------
// ImageMagick Detection
// -------------------------------------------------
Process {
id: checkMagickProcess
command: ["sh", "-c", "command -v magick"]
running: false
stdout: StdioCollector {}
stderr: StdioCollector {}
onExited: function (exitCode) {
root.imageMagickAvailable = (exitCode === 0);
root.initialized = true;
if (root.imageMagickAvailable) {
Logger.i("ImageCache", "ImageMagick available");
} else {
Logger.w("ImageCache", "ImageMagick not found, using Qt fallback");
}
}
}
}
@@ -0,0 +1,108 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.Noctalia
Singleton {
id: root
signal pluginProviderRegistryUpdated
// Plugin provider storage
property var pluginProviders: ({}) // { "plugin:pluginId": component }
property var pluginProviderMetadata: ({}) // { "plugin:pluginId": metadata }
// Persistent provider instances survive LauncherCore destruction/recreation
// so plugins don't re-parse large datasets on every launcher open.
property var providerInstances: ({}) // { "plugin:pluginId": instance }
function init() {
Logger.i("LauncherProviderRegistry", "Service started");
}
// Register a plugin launcher provider and instantiate it immediately
function registerPluginProvider(pluginId, component, metadata) {
if (!pluginId || !component) {
Logger.e("LauncherProviderRegistry", "Cannot register plugin provider: invalid parameters");
return false;
}
var providerId = "plugin:" + pluginId;
pluginProviders[providerId] = component;
pluginProviderMetadata[providerId] = metadata || {};
// Instantiate immediately so data loading starts in the background
var pluginApi = PluginService.getPluginAPI(pluginId);
if (pluginApi) {
var instance = component.createObject(null, {
pluginApi: pluginApi
});
if (instance) {
providerInstances[providerId] = instance;
if (instance.init)
instance.init();
Logger.i("LauncherProviderRegistry", "Registered and instantiated plugin provider:", providerId);
} else {
Logger.e("LauncherProviderRegistry", "Failed to instantiate plugin provider:", providerId);
}
}
root.pluginProviderRegistryUpdated();
return true;
}
// Unregister a plugin launcher provider
function unregisterPluginProvider(pluginId) {
var providerId = "plugin:" + pluginId;
if (!pluginProviders[providerId]) {
Logger.w("LauncherProviderRegistry", "Plugin provider not registered:", providerId);
return false;
}
if (providerInstances[providerId]) {
providerInstances[providerId].destroy();
delete providerInstances[providerId];
}
delete pluginProviders[providerId];
delete pluginProviderMetadata[providerId];
Logger.i("LauncherProviderRegistry", "Unregistered plugin provider:", providerId);
root.pluginProviderRegistryUpdated();
return true;
}
// Get list of registered plugin provider IDs
function getPluginProviders() {
return Object.keys(pluginProviders);
}
// Get the live provider instance by ID
function getProviderInstance(providerId) {
return providerInstances[providerId] || null;
}
// Get provider component by ID
function getProviderComponent(providerId) {
return pluginProviders[providerId] || null;
}
// Get provider metadata by ID
function getProviderMetadata(providerId) {
return pluginProviderMetadata[providerId] || null;
}
// Check if ID is a plugin provider
function isPluginProvider(id) {
return id.startsWith("plugin:");
}
// Check if provider exists
function hasProvider(providerId) {
return providerId in pluginProviders;
}
}
@@ -0,0 +1,421 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.Compositor
Singleton {
id: root
// A ref. to the lockScreen, so it's accessible from anywhere.
property var lockScreen: null
// Panels
property var registeredPanels: ({})
property var openedPanel: null
property var closingPanel: null
property bool closedImmediately: false
// Overlay launcher state (separate from normal panels)
property bool overlayLauncherOpen: false
property var overlayLauncherScreen: null
property var overlayLauncherCore: null // Reference to LauncherCore when overlay is active
// Brief window after panel opens where Exclusive keyboard is allowed on Hyprland
// This allows text inputs to receive focus, then switches to OnDemand for click-to-close
property bool isInitializingKeyboard: false
// Global state for keybind recording components to block global shortcuts
property bool isKeybindRecording: false
signal willOpen
signal didClose
// Background slot assignments for dynamic panel background rendering
// Slot 0: currently opening/open panel, Slot 1: closing panel
property var backgroundSlotAssignments: [null, null]
signal slotAssignmentChanged(int slotIndex, var panel)
function assignToSlot(slotIndex, panel) {
if (backgroundSlotAssignments[slotIndex] !== panel) {
var newAssignments = backgroundSlotAssignments.slice();
newAssignments[slotIndex] = panel;
backgroundSlotAssignments = newAssignments;
slotAssignmentChanged(slotIndex, panel);
}
}
// Popup menu windows (one per screen) - used for both tray menus and context menus
property var popupMenuWindows: ({})
signal popupMenuWindowRegistered(var screen)
// Register this panel (called after panel is loaded)
function registerPanel(panel) {
registeredPanels[panel.objectName] = panel;
Logger.d("PanelService", "Registered panel:", panel.objectName);
}
// Register popup menu window for a screen
function registerPopupMenuWindow(screen, window) {
if (!screen || !window)
return;
var key = screen.name;
popupMenuWindows[key] = window;
Logger.d("PanelService", "Registered popup menu window for screen:", key);
popupMenuWindowRegistered(screen);
}
// Unregister popup menu window for a screen (called on destruction)
function unregisterPopupMenuWindow(screen) {
if (!screen)
return;
var key = screen.name;
delete popupMenuWindows[key];
Logger.d("PanelService", "Unregistered popup menu window for screen:", key);
}
// Get popup menu window for a screen
function getPopupMenuWindow(screen) {
if (!screen)
return null;
return popupMenuWindows[screen.name] || null;
}
// Show a context menu with proper handling for all compositors
// Optional targetItem: if provided, menu will be horizontally centered on this item instead of anchorItem
function showContextMenu(contextMenu, anchorItem, screen, targetItem) {
if (!contextMenu || !anchorItem)
return;
// Close any previously opened context menu first
closeContextMenu(screen);
var popupMenuWindow = getPopupMenuWindow(screen);
if (popupMenuWindow) {
popupMenuWindow.showContextMenu(contextMenu);
contextMenu.openAtItem(anchorItem, screen, targetItem);
}
}
// Close any open context menu or popup menu window
function closeContextMenu(screen) {
var popupMenuWindow = getPopupMenuWindow(screen);
if (popupMenuWindow && popupMenuWindow.visible) {
popupMenuWindow.close();
}
}
// Show a tray menu with proper handling for all compositors
// Returns true if menu was shown successfully
function showTrayMenu(screen, trayItem, trayMenu, anchorItem, menuX, menuY, widgetSection, widgetIndex) {
if (!trayItem || !trayMenu || !anchorItem)
return false;
// Close any previously opened menu first
closeContextMenu(screen);
trayMenu.trayItem = trayItem;
trayMenu.widgetSection = widgetSection;
trayMenu.widgetIndex = widgetIndex;
var popupMenuWindow = getPopupMenuWindow(screen);
if (popupMenuWindow) {
popupMenuWindow.open();
trayMenu.showAt(anchorItem, menuX, menuY);
} else {
return false;
}
return true;
}
// Close tray menu
function closeTrayMenu(screen) {
var popupMenuWindow = getPopupMenuWindow(screen);
if (popupMenuWindow) {
// This closes both the window and calls hideMenu on the tray menu
popupMenuWindow.close();
}
}
// Find a fallback screen, prioritizing 0x0 position (primary)
function findFallbackScreen() {
let primaryCandidate = null;
let firstScreen = null;
for (let i = 0; i < Quickshell.screens.length; i++) {
const s = Quickshell.screens[i];
if (s.x === 0 && s.y === 0) {
primaryCandidate = s;
}
if (!firstScreen) {
firstScreen = s;
}
}
return primaryCandidate || firstScreen || null;
}
// Returns a panel (loads it on-demand if not yet loaded)
// By default, if panel not found on screen, tries other screens (favoring 0x0)
// Pass fallback=false to disable this behavior
function getPanel(name, screen, fallback = true) {
if (!screen) {
Logger.d("PanelService", "missing screen for getPanel:", name);
// If no screen specified, return the first matching panel
for (var key in registeredPanels) {
if (key.startsWith(name + "-")) {
return registeredPanels[key];
}
}
return null;
}
var panelKey = `${name}-${screen.name}`;
// Check if panel is already loaded
if (registeredPanels[panelKey]) {
return registeredPanels[panelKey];
}
// If fallback enabled, try to find panel on another screen
if (fallback) {
// First try the primary screen (0x0)
var fallbackScreen = findFallbackScreen();
if (fallbackScreen && fallbackScreen.name !== screen.name) {
var fallbackKey = `${name}-${fallbackScreen.name}`;
if (registeredPanels[fallbackKey]) {
Logger.d("PanelService", "Panel fallback from", screen.name, "to", fallbackScreen.name);
return registeredPanels[fallbackKey];
}
}
// Try any other screen
for (var key in registeredPanels) {
if (key.startsWith(name + "-")) {
Logger.d("PanelService", "Panel fallback to first available:", key);
return registeredPanels[key];
}
}
}
Logger.w("PanelService", "Panel not found:", panelKey);
return null;
}
// Check if a panel exists
function hasPanel(name) {
return name in registeredPanels;
}
// Check if panels can be shown on a given screen (has bar enabled or allowPanelsOnScreenWithoutBar)
function canShowPanelsOnScreen(screen) {
const name = screen?.name || "";
const monitors = Settings.data.bar.monitors || [];
const allowPanelsOnScreenWithoutBar = Settings.data.general.allowPanelsOnScreenWithoutBar;
return allowPanelsOnScreenWithoutBar || monitors.length === 0 || monitors.includes(name);
}
// Find a screen that can show panels
function findScreenForPanels() {
for (let i = 0; i < Quickshell.screens.length; i++) {
if (canShowPanelsOnScreen(Quickshell.screens[i])) {
return Quickshell.screens[i];
}
}
return null;
}
// Timer to switch from Exclusive to OnDemand keyboard focus on Hyprland
Timer {
id: keyboardInitTimer
interval: 100
repeat: false
onTriggered: {
root.isInitializingKeyboard = false;
}
}
// Helper to keep only one panel open at any time
function willOpenPanel(panel) {
// Close overlay launcher if open
if (overlayLauncherOpen) {
overlayLauncherOpen = false;
overlayLauncherScreen = null;
}
if (openedPanel && openedPanel !== panel) {
// Move current panel to closing slot before closing it
closingPanel = openedPanel;
assignToSlot(1, closingPanel);
openedPanel.close();
}
// Assign new panel to open slot
openedPanel = panel;
assignToSlot(0, panel);
// Start keyboard initialization period (for Hyprland workaround)
if (panel && panel.exclusiveKeyboard) {
isInitializingKeyboard = true;
keyboardInitTimer.restart();
}
// emit signal
willOpen();
}
// Open launcher panel (handles both normal and overlay mode)
function openLauncher(screen) {
if (Settings.data.appLauncher.overviewLayer) {
// Close any regular panel first
if (openedPanel) {
closingPanel = openedPanel;
assignToSlot(1, closingPanel);
openedPanel.close();
openedPanel = null;
}
// Open overlay launcher
overlayLauncherOpen = true;
overlayLauncherScreen = screen;
willOpen();
} else {
// Normal mode - use the SmartPanel
var panel = getPanel("launcherPanel", screen);
if (panel)
panel.open();
}
}
// Toggle launcher panel
function toggleLauncher(screen) {
if (Settings.data.appLauncher.overviewLayer) {
if (overlayLauncherOpen && overlayLauncherScreen === screen) {
closeOverlayLauncher();
} else {
openLauncher(screen);
}
} else {
var panel = getPanel("launcherPanel", screen);
if (panel)
panel.toggle();
}
}
// Close overlay launcher
function closeOverlayLauncher() {
if (overlayLauncherOpen) {
overlayLauncherOpen = false;
overlayLauncherScreen = null;
didClose();
}
}
// Close overlay launcher immediately (for app launches)
function closeOverlayLauncherImmediately() {
if (overlayLauncherOpen) {
closedImmediately = true;
overlayLauncherOpen = false;
overlayLauncherScreen = null;
didClose();
}
}
// ==================== Unified Launcher API ====================
// These methods work for both normal (SmartPanel) and overlay modes
function isLauncherOpen(screen) {
if (Settings.data.appLauncher.overviewLayer) {
return overlayLauncherOpen && overlayLauncherScreen === screen;
} else {
var panel = getPanel("launcherPanel", screen);
return panel ? panel.isPanelOpen : false;
}
}
function getLauncherSearchText(screen) {
if (Settings.data.appLauncher.overviewLayer) {
return overlayLauncherCore ? overlayLauncherCore.searchText : "";
} else {
var panel = getPanel("launcherPanel", screen);
return panel ? panel.searchText : "";
}
}
function setLauncherSearchText(screen, text) {
if (Settings.data.appLauncher.overviewLayer) {
if (overlayLauncherCore)
overlayLauncherCore.setSearchText(text);
} else {
var panel = getPanel("launcherPanel", screen);
if (panel)
panel.setSearchText(text);
}
}
function openLauncherWithSearch(screen, searchText) {
if (Settings.data.appLauncher.overviewLayer) {
openLauncher(screen);
// Set search text after core is ready
Qt.callLater(() => {
if (overlayLauncherCore)
overlayLauncherCore.setSearchText(searchText);
});
} else {
var panel = getPanel("launcherPanel", screen);
if (panel) {
panel.open();
panel.setSearchText(searchText);
}
}
}
function closeLauncher(screen) {
if (Settings.data.appLauncher.overviewLayer) {
closeOverlayLauncher();
} else {
var panel = getPanel("launcherPanel", screen);
if (panel)
panel.close();
}
}
// Close any open panel (for general use)
function closePanel() {
if (overlayLauncherOpen) {
closeOverlayLauncher();
} else if (openedPanel && openedPanel.close) {
openedPanel.close();
}
}
function closedPanel(panel) {
if (openedPanel && openedPanel === panel) {
openedPanel = null;
assignToSlot(0, null);
}
if (closingPanel && closingPanel === panel) {
closingPanel = null;
assignToSlot(1, null);
}
// Reset keyboard init state
isInitializingKeyboard = false;
keyboardInitTimer.stop();
// emit signal
didClose();
}
// Close panels when compositor overview opens (if setting is enabled)
Connections {
target: CompositorService
enabled: Settings.data.bar.hideOnOverview
function onOverviewActiveChanged() {
if (CompositorService.overviewActive && root.openedPanel) {
root.openedPanel.close();
}
}
}
}
@@ -0,0 +1,143 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.UI
Singleton {
id: root
// Track if the settings window is open
property bool isWindowOpen: false
// Reference to the window (set by SettingsPanelWindow)
property var settingsWindow: null
// Requested tab when opening
property int requestedTab: 0
// Requested subtab when opening (-1 means no specific subtab)
property int requestedSubTab: -1
// Requested entry for search navigation
property var requestedEntry: null
signal windowOpened
signal windowClosed
function openToEntry(entry, screen) {
if (Settings.data.ui.settingsPanelMode === "window") {
requestedEntry = entry;
if (settingsWindow) {
settingsWindow.visible = true;
isWindowOpen = true;
windowOpened();
settingsWindow.navigateToEntry(entry);
}
} else {
if (!screen) {
Logger.w("SettingsPanelService", "Screen parameter required for panel mode");
return;
}
var settingsPanel = PanelService.getPanel("settingsPanel", screen);
if (settingsPanel) {
settingsPanel.requestedEntry = entry;
settingsPanel.open();
}
}
}
// Unified function to open settings to a specific tab and subtab
// Respects user's settingsPanelMode setting (window vs panel)
// For panel mode, screen parameter is required
function openToTab(tab, subTab, screen) {
const tabId = tab !== undefined ? tab : 0;
const subTabId = subTab !== undefined ? subTab : -1;
if (Settings.data.ui.settingsPanelMode === "window") {
requestedTab = tabId;
requestedSubTab = subTabId;
if (settingsWindow) {
settingsWindow.visible = true;
isWindowOpen = true;
windowOpened();
settingsWindow.navigateTo(tabId, subTabId);
}
} else {
if (!screen) {
Logger.w("SettingsPanelService", "Screen parameter required for panel mode");
return;
}
var settingsPanel = PanelService.getPanel("settingsPanel", screen);
if (settingsPanel) {
settingsPanel.openToTab(tabId, subTabId);
}
}
}
function openWindow(tab) {
requestedTab = tab !== undefined ? tab : 0;
requestedSubTab = -1;
if (settingsWindow) {
settingsWindow.visible = true;
isWindowOpen = true;
windowOpened();
settingsWindow.navigateTo(requestedTab, -1);
}
}
function closeWindow() {
if (settingsWindow) {
settingsWindow.visible = false;
isWindowOpen = false;
windowClosed();
}
}
function toggleWindow(tab) {
if (isWindowOpen) {
closeWindow();
} else {
openWindow(tab);
}
}
// Unified toggle: opens to tab/subtab if closed, closes if open
// Respects settingsPanelMode setting
function toggle(tab, subTab, screen) {
const tabId = tab !== undefined ? tab : 0;
const subTabId = subTab !== undefined ? subTab : -1;
if (Settings.data.ui.settingsPanelMode === "window") {
if (isWindowOpen) {
closeWindow();
} else {
openToTab(tabId, subTabId);
}
} else {
if (!screen) {
Logger.w("SettingsPanelService", "Screen parameter required for panel mode");
return;
}
var settingsPanel = PanelService.getPanel("settingsPanel", screen);
if (settingsPanel?.isPanelOpen) {
settingsPanel.close();
} else {
settingsPanel?.openToTab(tabId, subTabId);
}
}
}
// Unified close for both modes
function close(screen) {
if (Settings.data.ui.settingsPanelMode === "window") {
closeWindow();
} else {
if (!screen)
return;
var settingsPanel = PanelService.getPanel("settingsPanel", screen);
settingsPanel?.close();
}
}
}
@@ -0,0 +1,158 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Compositor
import qs.Services.Power
import qs.Services.System
Singleton {
id: root
property var searchIndex: []
FileView {
path: Quickshell.shellDir + "/Assets/settings-search-index.json"
watchChanges: false
printErrors: false
onLoaded: {
try {
root.searchIndex = JSON.parse(text());
} catch (e) {
root.searchIndex = [];
}
}
}
readonly property var _roots: ({
"CompositorService": CompositorService,
"Settings": Settings,
"Quickshell": Quickshell,
"IdleService": IdleService,
"SystemStatService": SystemStatService,
"SoundService": SoundService
})
function isEntryVisible(entry) {
if (!entry.visibleWhen || entry.visibleWhen.length === 0)
return true;
for (let i = 0; i < entry.visibleWhen.length; i++) {
if (!_evalCondition(entry.visibleWhen[i]))
return false;
}
return true;
}
function _resolveValue(path) {
const parts = path.split(".");
const rootObj = _roots[parts[0]];
if (rootObj === undefined)
return undefined;
let obj = rootObj;
for (let i = 1; i < parts.length; i++) {
if (obj === undefined || obj === null)
return undefined;
let key = parts[i];
if (key.endsWith("?"))
key = key.slice(0, -1);
obj = obj[key];
}
return obj;
}
function _splitAnd(expr) {
const parts = [];
let depth = 0;
let current = "";
for (let i = 0; i < expr.length; i++) {
const ch = expr[i];
if (ch === "(")
depth++;
else if (ch === ")")
depth--;
else if (depth === 0 && ch === "&" && i + 1 < expr.length && expr[i + 1] === "&") {
parts.push(current);
current = "";
i++;
continue;
}
current += ch;
}
parts.push(current);
return parts;
}
function _evalCondition(expr) {
expr = expr.trim();
// Strip outer parentheses
if (expr.startsWith("(") && expr.endsWith(")")) {
let depth = 0;
let allWrapped = true;
for (let i = 0; i < expr.length - 1; i++) {
if (expr[i] === "(")
depth++;
else if (expr[i] === ")")
depth--;
if (depth === 0) {
allWrapped = false;
break;
}
}
if (allWrapped)
return _evalCondition(expr.slice(1, -1));
}
// AND: all parts must be true
if (expr.includes("&&")) {
const parts = _splitAnd(expr);
if (parts.length > 1) {
for (let i = 0; i < parts.length; i++) {
if (!_evalCondition(parts[i]))
return false;
}
return true;
}
}
// Negation
if (expr.startsWith("!"))
return !_evalCondition(expr.slice(1).trim());
// Literal false
if (expr === "false")
return false;
// Strip nullish coalescing fallback
const nullishMatch = expr.match(/^(.+?)\s*\?\?\s*(?:false|true)\s*$/);
if (nullishMatch)
return _evalCondition(nullishMatch[1]);
// === comparison
let m = expr.match(/^(.+?)\s*===\s*"([^"]*)"\s*$/);
if (m)
return _resolveValue(m[1].trim()) === m[2];
// !== comparison
m = expr.match(/^(.+?)\s*!==\s*"([^"]*)"\s*$/);
if (m)
return _resolveValue(m[1].trim()) !== m[2];
// > comparison
m = expr.match(/^(.+?)\s*>\s*(\d+)\s*$/);
if (m)
return _resolveValue(m[1].trim()) > parseInt(m[2]);
// Simple property path resolve and return truthiness
const val = _resolveValue(expr);
if (val !== undefined)
return !!val;
// Unrecognized expression assume visible
return true;
}
}
@@ -0,0 +1,31 @@
pragma Singleton
import QtQuick
import Quickshell
Singleton {
id: root
// Simple signal-based notification system
// actionLabel: optional label for clickable action link
// actionCallback: optional function to call when action is clicked
signal notify(string title, string description, string icon, string type, int duration, string actionLabel, var actionCallback)
signal dismiss
// Convenience methods
function showNotice(title, description = "", icon = "", duration = 3000, actionLabel = "", actionCallback = null) {
notify(title, description, icon, "notice", duration, actionLabel, actionCallback);
}
function showWarning(title, description = "", duration = 4000, actionLabel = "", actionCallback = null) {
notify(title, description, "", "warning", duration, actionLabel, actionCallback);
}
function showError(title, description = "", duration = 6000, actionLabel = "", actionCallback = null) {
notify(title, description, "", "error", duration, actionLabel, actionCallback);
}
function dismissToast() {
dismiss();
}
}
@@ -0,0 +1,133 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Commons
import qs.Modules.Tooltip
Singleton {
id: root
property var activeTooltip: null
property var pendingTooltip: null // Track tooltip being created
property Component tooltipComponent: Component {
Tooltip {}
}
function show(target, content, direction, delay, fontFamily) {
if (!Settings.data.ui.tooltipsEnabled) {
return;
}
// Don't create if no content
if (!target || !content || (Array.isArray(content) && content.length === 0)) {
Logger.i("Tooltip", "No target or content");
return;
}
// Cancel any pending tooltip (same or different target)
if (pendingTooltip) {
pendingTooltip.hideImmediately();
pendingTooltip.destroy();
pendingTooltip = null;
}
// If we have an active tooltip for a different target, hide it
if (activeTooltip && activeTooltip.targetItem !== target) {
activeTooltip.hideImmediately();
// Don't destroy immediately - let it clean itself up
activeTooltip = null;
}
// If we already have a tooltip for this target, just update it
if (activeTooltip && activeTooltip.targetItem === target) {
activeTooltip.updateContent(content);
return activeTooltip;
}
// Create new tooltip instance
const newTooltip = tooltipComponent.createObject(null);
if (newTooltip) {
// Track as pending until it's visible
pendingTooltip = newTooltip;
// Connect cleanup when tooltip hides
newTooltip.visibleChanged.connect(() => {
if (!newTooltip.visible) {
// Clean up after a delay to avoid interfering with new tooltips
Qt.callLater(() => {
if (newTooltip && !newTooltip.visible) {
if (activeTooltip === newTooltip) {
activeTooltip = null;
}
if (pendingTooltip === newTooltip) {
pendingTooltip = null;
}
newTooltip.destroy();
}
});
} else {
// Tooltip is now visible, move from pending to active
if (pendingTooltip === newTooltip) {
activeTooltip = newTooltip;
pendingTooltip = null;
}
}
});
// Show the tooltip
newTooltip.show(target, content, direction || "auto", delay || Style.tooltipDelay, fontFamily);
return newTooltip;
} else {
Logger.e("Tooltip", "Failed to create tooltip instance");
}
return null;
}
function hide(target) {
// If target is provided, only hide if tooltip belongs to that target
if (target) {
if (pendingTooltip && pendingTooltip.targetItem === target) {
pendingTooltip.hide();
}
if (activeTooltip && activeTooltip.targetItem === target) {
activeTooltip.hide();
}
} else {
if (pendingTooltip) {
pendingTooltip.hide();
}
if (activeTooltip) {
activeTooltip.hide();
}
}
}
function hideImmediately() {
if (pendingTooltip) {
pendingTooltip.hideImmediately();
pendingTooltip.destroy();
pendingTooltip = null;
}
if (activeTooltip) {
activeTooltip.hideImmediately();
activeTooltip.destroy();
activeTooltip = null;
}
}
function updateContent(newContent) {
if (activeTooltip) {
activeTooltip.updateContent(newContent);
}
}
// Backward compatibility alias
function updateText(newText) {
updateContent(newText);
}
}
@@ -0,0 +1,274 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Commons
Singleton {
id: root
// State
property bool fetching: false
property bool initialSearchScheduled: false
property var currentResults: []
property var currentMeta: ({})
property string lastError: ""
property string currentQuery: ""
property int currentPage: 1
property int lastPage: 1
// Search parameters
property string categories: "111" // general,anime,people (all enabled by default)
property string purity: "100" // sfw
property string sorting: "relevance" // date_added, relevance, random, views, favorites, toplist
property string order: "desc" // desc, asc
property string topRange: "1M" // 1d, 3d, 1w, 1M, 3M, 6M, 1y
property string seed: "" // For random sorting
property string minResolution: "" // e.g., "1920x1080"
property string resolutions: "" // e.g., "1920x1080,1920x1200"
property string ratios: "" // e.g., "16x9,16x10"
property string colors: "" // Color hex codes
// API Key Priority: Environment Variable > Local Settings
readonly property string envApiKey: Quickshell.env("NOCTALIA_WALLHAVEN_API_KEY") || ""
readonly property string apiKey: envApiKey !== "" ? envApiKey : (Settings.data.wallpaper.wallhavenApiKey || "")
readonly property bool apiKeyManagedByEnv: envApiKey !== ""
// Signals
signal searchCompleted(var results, var meta)
signal searchFailed(string error)
signal wallpaperDownloaded(string wallpaperId, string localPath)
// Base API URL
readonly property string apiBaseUrl: "https://wallhaven.cc/api/v1"
// -------------------------------------------------
function search(query, page) {
if (fetching) {
return;
}
// Reset initial search flag once we start a search
if (initialSearchScheduled) {
initialSearchScheduled = false;
}
fetching = true;
lastError = "";
currentQuery = query || "";
currentPage = page || 1;
var url = apiBaseUrl + "/search";
var params = [];
if (currentQuery) {
params.push("q=" + encodeURIComponent(currentQuery));
}
params.push("categories=" + categories);
// Safety: Force SFW if no purity selected to prevent API error
var safePurity = (purity === "000") ? "100" : purity;
params.push("purity=" + safePurity);
params.push("sorting=" + sorting);
params.push("order=" + order);
if (sorting === "toplist") {
params.push("topRange=" + topRange);
}
if (sorting === "random" && seed) {
params.push("seed=" + seed);
}
if (minResolution) {
params.push("atleast=" + minResolution);
}
if (resolutions) {
params.push("resolutions=" + resolutions);
}
if (ratios) {
params.push("ratios=" + ratios);
}
if (colors) {
params.push("colors=" + colors);
}
if (apiKey) {
params.push("apikey=" + apiKey);
}
params.push("page=" + currentPage);
url += "?" + params.join("&");
Logger.d("Wallhaven", "Searching:", url);
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
fetching = false;
if (xhr.status === 200) {
try {
var response = JSON.parse(xhr.responseText);
if (response.data && Array.isArray(response.data)) {
currentResults = response.data;
currentMeta = response.meta || {};
lastPage = currentMeta.last_page || 1;
// Store seed for random sorting
if (currentMeta.seed) {
seed = currentMeta.seed;
}
Logger.d("Wallhaven", "Search completed:", currentResults.length, "results, page", currentPage, "of", lastPage);
searchCompleted(currentResults, currentMeta);
} else {
var errorMsg = "Invalid API response";
lastError = errorMsg;
Logger.e("Wallhaven", errorMsg);
searchFailed(errorMsg);
}
} catch (e) {
var errorMsg = "Failed to parse API response: " + e.toString();
lastError = errorMsg;
Logger.e("Wallhaven", errorMsg);
searchFailed(errorMsg);
}
} else if (xhr.status === 429) {
var errorMsg = "Rate limit exceeded (45 requests/minute)";
lastError = errorMsg;
Logger.w("Wallhaven", errorMsg);
searchFailed(errorMsg);
} else if (xhr.status === 401) {
var errorMsg = "Invalid API Key. Please check your settings.";
lastError = errorMsg;
Logger.e("Wallhaven", errorMsg);
searchFailed(errorMsg);
} else {
var errorMsg = "API error: " + xhr.status;
lastError = errorMsg;
Logger.e("Wallhaven", "Search failed:", errorMsg);
searchFailed(errorMsg);
}
}
};
xhr.open("GET", url);
xhr.send();
}
// -------------------------------------------------
function getWallpaperUrl(wallpaper) {
// Use the 'path' field which contains the full resolution image URL
if (wallpaper.path) {
return wallpaper.path;
}
// Fallback to constructing URL from ID
if (wallpaper.id) {
var idPrefix = wallpaper.id.substring(0, 2);
return "https://w.wallhaven.cc/full/" + idPrefix + "/wallhaven-" + wallpaper.id + ".jpg";
}
return "";
}
// -------------------------------------------------
function getThumbnailUrl(wallpaper, size) {
// size: "small", "large", "original"
if (wallpaper.thumbs && wallpaper.thumbs[size]) {
return wallpaper.thumbs[size];
}
// Fallback
if (wallpaper.id) {
var idPrefix = wallpaper.id.substring(0, 2);
var sizeMap = {
"small": "small",
"large": "lg",
"original": "orig"
};
var sizePath = sizeMap[size] || "lg";
return "https://th.wallhaven.cc/" + sizePath + "/" + idPrefix + "/" + wallpaper.id + ".jpg";
}
return "";
}
// -------------------------------------------------
function downloadWallpaper(wallpaper, callback) {
var url = getWallpaperUrl(wallpaper);
if (!url) {
Logger.e("Wallhaven", "No URL available for wallpaper", wallpaper.id);
if (callback)
callback(false, "");
return;
}
var wallpaperId = wallpaper.id;
// Get the user's wallpaper directory
var wallpaperDir = Settings.preprocessPath(Settings.data.wallpaper.directory);
if (!wallpaperDir || wallpaperDir === "") {
wallpaperDir = Settings.defaultWallpapersDirectory;
}
// Ensure directory ends with /
if (!wallpaperDir.endsWith("/")) {
wallpaperDir += "/";
}
var localPath = wallpaperDir + "wallhaven_" + wallpaperId + ".jpg";
Logger.d("Wallhaven", "Downloading wallpaper", wallpaperId, "to", localPath);
// Use curl or wget to download the file, ensuring directory exists first
var downloadProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
id: downloadProcess
command: ["sh", "-c", "mkdir -p '` + wallpaperDir + `' && (curl -L -s -o '` + localPath + `' '` + url + `' || wget -q -O '` + localPath + `' '` + url + `')"]
}
`, root, "DownloadProcess_" + wallpaperId);
downloadProcess.exited.connect(function (exitCode) {
if (exitCode === 0) {
Logger.i("Wallhaven", "Wallpaper downloaded:", localPath);
wallpaperDownloaded(wallpaperId, localPath);
if (callback)
callback(true, localPath);
} else {
Logger.e("Wallhaven", "Failed to download wallpaper, exit code:", exitCode);
if (callback)
callback(false, "");
}
downloadProcess.destroy();
});
downloadProcess.running = true;
}
// -------------------------------------------------
function reset() {
currentResults = [];
currentMeta = {};
currentQuery = "";
currentPage = 1;
lastPage = 1;
seed = "";
lastError = "";
}
// -------------------------------------------------
function nextPage() {
if (currentPage < lastPage && !fetching) {
search(currentQuery, currentPage + 1);
}
}
// -------------------------------------------------
function previousPage() {
if (currentPage > 1 && !fetching) {
search(currentQuery, currentPage - 1);
}
}
}
File diff suppressed because it is too large Load Diff