fedora
This commit is contained in:
@@ -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 10–18 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).");
|
||||
}
|
||||
}
|
||||
+148
@@ -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 + "¤t_weather=true¤t=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: best‑effort 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 sub‑component
|
||||
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
Reference in New Issue
Block a user