This commit is contained in:
2026-05-28 21:45:21 +02:00
parent 653439ac24
commit a356a3d369
610 changed files with 205679 additions and 0 deletions
@@ -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