fedora
This commit is contained in:
@@ -0,0 +1,668 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Compositor
|
||||
import qs.Services.UI
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property bool isVisible: true
|
||||
|
||||
// Computed visibility that factors in compositor overview state
|
||||
readonly property bool effectivelyVisible: {
|
||||
if (!isVisible) {
|
||||
return false;
|
||||
}
|
||||
if (Settings.data.bar.hideOnOverview && CompositorService.overviewActive) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
property var readyBars: ({})
|
||||
|
||||
// Revision counter - increment when widget list structure changes (add/remove/reorder)
|
||||
// This triggers Bar.qml to re-sync its ListModels
|
||||
property int widgetsRevision: 0
|
||||
|
||||
// Registry to store actual widget instances
|
||||
// Key format: "screenName|section|widgetId|index"
|
||||
property var widgetInstances: ({})
|
||||
|
||||
signal activeWidgetsChanged
|
||||
signal barReadyChanged(string screenName)
|
||||
signal barAutoHideStateChanged(string screenName, bool hidden)
|
||||
signal barHoverStateChanged(string screenName, bool hovered)
|
||||
|
||||
// Track if a popup menu is open from the bar (prevents auto-hide)
|
||||
property bool popupOpen: false
|
||||
|
||||
// Auto-hide state per screen: { screenName: { hovered: bool, hidden: bool } }
|
||||
property var screenAutoHideState: ({})
|
||||
|
||||
// Get or create auto-hide state for a screen
|
||||
function getOrCreateAutoHideState(screenName) {
|
||||
if (!screenAutoHideState[screenName]) {
|
||||
screenAutoHideState[screenName] = {
|
||||
"hovered": false,
|
||||
"hidden": Settings.getBarDisplayModeForScreen(screenName) === "auto_hide"
|
||||
};
|
||||
}
|
||||
return screenAutoHideState[screenName];
|
||||
}
|
||||
|
||||
// Set hover state for a screen
|
||||
function setScreenHovered(screenName, hovered) {
|
||||
var state = getOrCreateAutoHideState(screenName);
|
||||
if (state.hovered !== hovered) {
|
||||
state.hovered = hovered;
|
||||
screenAutoHideState = Object.assign({}, screenAutoHideState);
|
||||
barHoverStateChanged(screenName, hovered);
|
||||
}
|
||||
}
|
||||
|
||||
// Set hidden state for a screen
|
||||
function setScreenHidden(screenName, hidden) {
|
||||
var state = getOrCreateAutoHideState(screenName);
|
||||
if (state.hidden !== hidden) {
|
||||
state.hidden = hidden;
|
||||
screenAutoHideState = Object.assign({}, screenAutoHideState);
|
||||
barAutoHideStateChanged(screenName, hidden);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if bar is hidden on a screen
|
||||
function isBarHidden(screenName) {
|
||||
var state = screenAutoHideState[screenName];
|
||||
return state ? state.hidden : false;
|
||||
}
|
||||
|
||||
// Check if bar is hovered on a screen
|
||||
function isBarHovered(screenName) {
|
||||
var state = screenAutoHideState[screenName];
|
||||
return state ? state.hovered : false;
|
||||
}
|
||||
|
||||
// Toggle bar visibility. In auto-hide mode, toggles the per-screen hidden
|
||||
// state without touching isVisible (so hover-to-show still works).
|
||||
// For non-auto-hide screens, toggles the global isVisible flag.
|
||||
function toggleVisibility() {
|
||||
// Check if any auto-hide screen is currently visible
|
||||
var anyAutoHideVisible = false;
|
||||
var hasAutoHideScreens = false;
|
||||
for (var screenName in screenAutoHideState) {
|
||||
if (Settings.getBarDisplayModeForScreen(screenName) === "auto_hide") {
|
||||
hasAutoHideScreens = true;
|
||||
if (!screenAutoHideState[screenName].hidden) {
|
||||
anyAutoHideVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle auto-hide screens (per-screen hidden state only)
|
||||
if (hasAutoHideScreens) {
|
||||
for (var screenName in screenAutoHideState) {
|
||||
if (Settings.getBarDisplayModeForScreen(screenName) === "auto_hide") {
|
||||
setScreenHidden(screenName, anyAutoHideVisible);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only toggle global visibility when no auto-hide screens exist,
|
||||
// otherwise it would permanently disable hover-to-show
|
||||
if (!hasAutoHideScreens) {
|
||||
isVisible = !isVisible;
|
||||
}
|
||||
}
|
||||
|
||||
// Show bar. In auto-hide mode, un-hides on screens with auto-hide enabled.
|
||||
// The bar stays visible until the user hovers and moves away.
|
||||
function show() {
|
||||
// Show auto-hide screens
|
||||
for (var screenName in screenAutoHideState) {
|
||||
if (Settings.getBarDisplayModeForScreen(screenName) === "auto_hide") {
|
||||
setScreenHidden(screenName, false);
|
||||
}
|
||||
}
|
||||
// Set global visibility (affects non-auto-hide screens)
|
||||
isVisible = true;
|
||||
}
|
||||
|
||||
// Hide bar. In auto-hide mode, sets per-screen hidden state without touching
|
||||
// isVisible so hover-to-show still works. For non-auto-hide screens, sets
|
||||
// global visibility to false.
|
||||
function hide() {
|
||||
var hasAutoHideScreens = false;
|
||||
for (var screenName in screenAutoHideState) {
|
||||
if (Settings.getBarDisplayModeForScreen(screenName) === "auto_hide") {
|
||||
setScreenHidden(screenName, true);
|
||||
hasAutoHideScreens = true;
|
||||
}
|
||||
}
|
||||
// Only set global visibility off when no auto-hide screens exist,
|
||||
// otherwise it would permanently disable hover-to-show
|
||||
if (!hasAutoHideScreens) {
|
||||
isVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Temporarily show the bar, then auto-hide after the configured delay.
|
||||
// Uses the same pattern as workspace switch: show, then emit unhover
|
||||
// to start the hide timer.
|
||||
function peek() {
|
||||
for (var screenName in screenAutoHideState) {
|
||||
if (Settings.getBarDisplayModeForScreen(screenName) === "auto_hide") {
|
||||
setScreenHidden(screenName, false);
|
||||
if (!isBarHovered(screenName)) {
|
||||
barHoverStateChanged(screenName, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
Logger.i("BarService", "Service started");
|
||||
}
|
||||
|
||||
// Bump widgetsRevision when settings are reloaded from an external file change
|
||||
// so Bar.qml re-syncs its widget ListModels with the updated widget configuration
|
||||
Connections {
|
||||
target: Settings
|
||||
function onSettingsReloaded() {
|
||||
Logger.d("BarService", "Settings reloaded externally, bumping widgetsRevision");
|
||||
root.widgetsRevision++;
|
||||
}
|
||||
}
|
||||
|
||||
// update bar's hidden state when mode changes
|
||||
Connections {
|
||||
target: Settings.data.bar
|
||||
function onDisplayModeChanged() {
|
||||
Logger.d("BarService", "Display mode changed to:", Settings.data.bar.displayMode);
|
||||
|
||||
// Only affect screens without displayMode overrides
|
||||
for (let screenName in screenAutoHideState) {
|
||||
if (!Settings.hasScreenOverride(screenName, "displayMode")) {
|
||||
var displayMode = Settings.getBarDisplayModeForScreen(screenName);
|
||||
if (displayMode === "auto_hide") {
|
||||
setScreenHidden(screenName, true);
|
||||
} else {
|
||||
if (screenAutoHideState[screenName].hidden) {
|
||||
setScreenHidden(screenName, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onScreenOverridesChanged() {
|
||||
Logger.d("BarService", "Screen overrides changed, re-evaluating auto-hide states");
|
||||
|
||||
// Re-evaluate auto-hide state for all screens
|
||||
for (let screenName in screenAutoHideState) {
|
||||
var displayMode = Settings.getBarDisplayModeForScreen(screenName);
|
||||
if (displayMode === "auto_hide") {
|
||||
if (!screenAutoHideState[screenName].hidden) {
|
||||
setScreenHidden(screenName, true);
|
||||
}
|
||||
} else {
|
||||
if (screenAutoHideState[screenName].hidden) {
|
||||
setScreenHidden(screenName, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track last workspace ID to detect actual workspace changes
|
||||
property var lastWorkspaceId: null
|
||||
|
||||
// Debounce rapid workspace switches to reduce load/unload races (SIGSEGV in QV4)
|
||||
property string _pendingWorkspaceScreen: ""
|
||||
|
||||
Timer {
|
||||
id: workspaceDebounceTimer
|
||||
interval: 80
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
var screen = root._pendingWorkspaceScreen;
|
||||
root._pendingWorkspaceScreen = "";
|
||||
if (screen) {
|
||||
setScreenHidden(screen, false);
|
||||
if (!root.isBarHovered(screen)) {
|
||||
barHoverStateChanged(screen, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace switch handler - directly show bar on the focused workspace screen
|
||||
Connections {
|
||||
target: CompositorService
|
||||
function onWorkspaceChanged() {
|
||||
if (!Settings.data.bar.showOnWorkspaceSwitch)
|
||||
return;
|
||||
if (Settings.data.bar.displayMode !== "auto_hide")
|
||||
return;
|
||||
|
||||
var ws = CompositorService.getCurrentWorkspace();
|
||||
if (!ws || !ws.output) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only trigger if workspace actually changed
|
||||
var currentWsId = ws.id;
|
||||
if (currentWsId === root.lastWorkspaceId) {
|
||||
return;
|
||||
}
|
||||
root.lastWorkspaceId = currentWsId;
|
||||
|
||||
var screenName = ws.output || "";
|
||||
Logger.d("BarService", "Workspace switched to:", currentWsId, "on screen:", screenName);
|
||||
|
||||
// Debounce: rapid switches (e.g. external monitor ↔ laptop) cause overlapping
|
||||
// bar load/unload; 80ms delay coalesces them and reduces QV4 incubation races
|
||||
root._pendingWorkspaceScreen = screenName;
|
||||
workspaceDebounceTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
// Function for the Bar to call when it's ready
|
||||
function registerBar(screenName) {
|
||||
if (!readyBars[screenName]) {
|
||||
readyBars[screenName] = true;
|
||||
Logger.d("BarService", "Bar is ready on screen:", screenName);
|
||||
barReadyChanged(screenName);
|
||||
}
|
||||
}
|
||||
|
||||
// Function for the Dock to check if the bar is ready
|
||||
function isBarReady(screenName) {
|
||||
return readyBars[screenName] || false;
|
||||
}
|
||||
|
||||
// Register a widget instance
|
||||
function registerWidget(screenName, section, widgetId, index, instance) {
|
||||
const key = [screenName, section, widgetId, index].join("|");
|
||||
widgetInstances[key] = {
|
||||
"key": key,
|
||||
"screenName": screenName,
|
||||
"section": section,
|
||||
"widgetId": widgetId,
|
||||
"index": index,
|
||||
"instance": instance
|
||||
};
|
||||
|
||||
Logger.d("BarService", "Registered widget:", key);
|
||||
root.activeWidgetsChanged();
|
||||
}
|
||||
|
||||
// Unregister a widget instance
|
||||
function unregisterWidget(screenName, section, widgetId, index) {
|
||||
const key = [screenName, section, widgetId, index].join("|");
|
||||
delete widgetInstances[key];
|
||||
Logger.d("BarService", "Unregistered widget:", key);
|
||||
root.activeWidgetsChanged();
|
||||
}
|
||||
|
||||
// Lookup a specific widget instance (returns the actual QML instance)
|
||||
function lookupWidget(widgetId, screenName = null, section = null, index = null) {
|
||||
// If looking for a specific instance
|
||||
if (screenName && section !== null) {
|
||||
for (var key in widgetInstances) {
|
||||
var widget = widgetInstances[key];
|
||||
if (!widget)
|
||||
continue;
|
||||
if (widget.widgetId === widgetId && widget.screenName === screenName && widget.section === section) {
|
||||
if (index === null) {
|
||||
return widget.instance;
|
||||
} else if (widget.index == index) {
|
||||
return widget.instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return first match if no specific screen/section specified
|
||||
for (var key in widgetInstances) {
|
||||
var widget = widgetInstances[key];
|
||||
if (!widget)
|
||||
continue;
|
||||
if (widget.widgetId === widgetId) {
|
||||
if (!screenName || widget.screenName === screenName) {
|
||||
if (section === null || widget.section === section) {
|
||||
return widget.instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get all instances of a widget type
|
||||
function getAllWidgetInstances(widgetId = null, screenName = null, section = null) {
|
||||
var instances = [];
|
||||
|
||||
for (var key in widgetInstances) {
|
||||
var widget = widgetInstances[key];
|
||||
if (!widget)
|
||||
continue;
|
||||
|
||||
var matches = true;
|
||||
if (widgetId && widget.widgetId !== widgetId)
|
||||
matches = false;
|
||||
if (screenName && widget.screenName !== screenName)
|
||||
matches = false;
|
||||
if (section !== null && widget.section !== section)
|
||||
matches = false;
|
||||
|
||||
if (matches) {
|
||||
instances.push(widget.instance);
|
||||
}
|
||||
}
|
||||
|
||||
return instances;
|
||||
}
|
||||
|
||||
// Get widget with full metadata
|
||||
function getWidgetWithMetadata(widgetId, screenName = null, section = null) {
|
||||
for (var key in widgetInstances) {
|
||||
var widget = widgetInstances[key];
|
||||
if (!widget)
|
||||
continue;
|
||||
if (widget.widgetId === widgetId) {
|
||||
if (!screenName || widget.screenName === screenName) {
|
||||
if (section === null || widget.section === section) {
|
||||
return widget;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get all widgets in a specific section
|
||||
function getWidgetsBySection(section, screenName = null) {
|
||||
var widgetEntries = [];
|
||||
|
||||
for (var key in widgetInstances) {
|
||||
var widget = widgetInstances[key];
|
||||
if (!widget)
|
||||
continue;
|
||||
if (widget.section === section) {
|
||||
if (!screenName || widget.screenName === screenName) {
|
||||
widgetEntries.push(widget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by index to maintain order
|
||||
widgetEntries.sort(function (a, b) {
|
||||
return (a.index || 0) - (b.index || 0);
|
||||
});
|
||||
|
||||
// Return just the instances
|
||||
return widgetEntries.map(function (w) {
|
||||
return w.instance;
|
||||
});
|
||||
}
|
||||
|
||||
// Get all registered widgets (for debugging)
|
||||
function getAllRegisteredWidgets() {
|
||||
var result = [];
|
||||
for (var key in widgetInstances) {
|
||||
var widget = widgetInstances[key];
|
||||
if (!widget)
|
||||
continue;
|
||||
result.push({
|
||||
"key": key,
|
||||
"widgetId": widget.widgetId,
|
||||
"section": widget.section,
|
||||
"screenName": widget.screenName,
|
||||
"index": widget.index
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check if a widget type exists in a section
|
||||
function hasWidget(widgetId, section = null, screenName = null) {
|
||||
for (var key in widgetInstances) {
|
||||
var widget = widgetInstances[key];
|
||||
if (!widget)
|
||||
continue;
|
||||
if (widget.widgetId === widgetId) {
|
||||
if (section === null || widget.section === section) {
|
||||
if (!screenName || widget.screenName === screenName) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Unregister all widget instances for a plugin (used during hot reload)
|
||||
// Note: We don't destroy instances here - the Loader manages that when the component is unregistered
|
||||
function destroyPluginWidgetInstances(pluginId) {
|
||||
var widgetId = "plugin:" + pluginId;
|
||||
var keysToRemove = [];
|
||||
|
||||
// Find all instances of this plugin's widget
|
||||
for (var key in widgetInstances) {
|
||||
var widget = widgetInstances[key];
|
||||
if (widget && widget.widgetId === widgetId) {
|
||||
keysToRemove.push(key);
|
||||
Logger.d("BarService", "Unregistering plugin widget instance:", key);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from registry
|
||||
for (var i = 0; i < keysToRemove.length; i++) {
|
||||
delete widgetInstances[keysToRemove[i]];
|
||||
}
|
||||
|
||||
if (keysToRemove.length > 0) {
|
||||
Logger.i("BarService", "Unregistered", keysToRemove.length, "instance(s) of plugin widget:", widgetId);
|
||||
root.activeWidgetsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// Get pill direction for a widget instance
|
||||
function getPillDirection(widgetInstance) {
|
||||
try {
|
||||
if (widgetInstance.section === "left") {
|
||||
return false;
|
||||
} else if (widgetInstance.section === "right") {
|
||||
return true;
|
||||
} else {
|
||||
// middle section
|
||||
if (widgetInstance.sectionWidgetIndex < widgetInstance.sectionWidgetsCount / 2) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.e(e);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getTooltipDirection(screenName) {
|
||||
const position = Settings.getBarPositionForScreen(screenName);
|
||||
switch (position) {
|
||||
case "right":
|
||||
return "left";
|
||||
case "left":
|
||||
return "right";
|
||||
case "bottom":
|
||||
return "top";
|
||||
default:
|
||||
return "bottom";
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to close any existing dialogs in a popup menu window
|
||||
function closeExistingDialogs(popupMenuWindow) {
|
||||
if (!popupMenuWindow || !popupMenuWindow.dialogParent)
|
||||
return;
|
||||
|
||||
var dialogParent = popupMenuWindow.dialogParent;
|
||||
for (var i = dialogParent.children.length - 1; i >= 0; i--) {
|
||||
var child = dialogParent.children[i];
|
||||
if (child && typeof child.close === "function") {
|
||||
child.close();
|
||||
}
|
||||
}
|
||||
popupMenuWindow.hasDialog = false;
|
||||
}
|
||||
|
||||
// Open widget settings dialog for a bar widget
|
||||
// Parameters:
|
||||
// screen: The screen to show the dialog on
|
||||
// section: Section id ("left", "center", "right")
|
||||
// index: Widget index in section
|
||||
// widgetId: Widget type id (e.g., "Volume")
|
||||
// widgetData: Current widget settings object
|
||||
function openWidgetSettings(screen, section, index, widgetId, widgetData) {
|
||||
// Get the popup menu window to use as parent (avoids clipping issues with bar height)
|
||||
var popupMenuWindow = PanelService.getPopupMenuWindow(screen);
|
||||
if (!popupMenuWindow) {
|
||||
Logger.e("BarService", "No popup menu window found for screen");
|
||||
return;
|
||||
}
|
||||
|
||||
// Close any existing dialogs first to prevent stacking
|
||||
closeExistingDialogs(popupMenuWindow);
|
||||
|
||||
if (PanelService.openedPanel) {
|
||||
PanelService.openedPanel.close();
|
||||
}
|
||||
|
||||
var component = Qt.createComponent(Quickshell.shellDir + "/Modules/Panels/Settings/Bar/BarWidgetSettingsDialog.qml");
|
||||
|
||||
function instantiateAndOpen() {
|
||||
// Use dialogParent (Item) instead of window directly for proper Popup anchoring
|
||||
var dialog = component.createObject(popupMenuWindow.dialogParent, {
|
||||
"widgetIndex": index,
|
||||
"widgetData": widgetData,
|
||||
"widgetId": widgetId,
|
||||
"sectionId": section,
|
||||
"screen": screen
|
||||
});
|
||||
|
||||
if (dialog) {
|
||||
dialog.updateWidgetSettings.connect((sec, idx, settings) => {
|
||||
var screenName = screen?.name || "";
|
||||
if (Settings.hasScreenOverride(screenName, "widgets")) {
|
||||
var overrideWidgets = Settings.getBarWidgetsForScreen(screenName);
|
||||
if (overrideWidgets && overrideWidgets[sec] && idx < overrideWidgets[sec].length) {
|
||||
overrideWidgets[sec][idx] = Object.assign({}, overrideWidgets[sec][idx], settings);
|
||||
Settings.setScreenOverride(screenName, "widgets", overrideWidgets);
|
||||
}
|
||||
} else {
|
||||
var widgets = Settings.data.bar.widgets[sec];
|
||||
if (widgets && idx < widgets.length) {
|
||||
widgets[idx] = Object.assign({}, widgets[idx], settings);
|
||||
Settings.data.bar.widgets[sec] = widgets;
|
||||
Settings.saveImmediate();
|
||||
}
|
||||
}
|
||||
});
|
||||
// Enable keyboard focus for the popup menu window when dialog is open
|
||||
popupMenuWindow.hasDialog = true;
|
||||
// Close the popup menu window when dialog closes
|
||||
dialog.closed.connect(() => {
|
||||
popupMenuWindow.hasDialog = false;
|
||||
popupMenuWindow.close();
|
||||
dialog.destroy();
|
||||
});
|
||||
// Show the popup menu window and open the dialog
|
||||
popupMenuWindow.open();
|
||||
dialog.open();
|
||||
} else {
|
||||
Logger.e("BarService", "Failed to create widget settings dialog");
|
||||
}
|
||||
}
|
||||
|
||||
if (component.status === Component.Ready) {
|
||||
instantiateAndOpen();
|
||||
} else if (component.status === Component.Error) {
|
||||
Logger.e("BarService", "Error loading widget settings dialog:", component.errorString());
|
||||
} else {
|
||||
component.statusChanged.connect(function () {
|
||||
if (component.status === Component.Ready) {
|
||||
instantiateAndOpen();
|
||||
} else if (component.status === Component.Error) {
|
||||
Logger.e("BarService", "Error loading widget settings dialog:", component.errorString());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Open plugin settings dialog
|
||||
// Parameters:
|
||||
// screen: The screen to show the dialog on
|
||||
// pluginManifest: The plugin's manifest object (must have entryPoints.settings)
|
||||
function openPluginSettings(screen, pluginManifest) {
|
||||
if (!pluginManifest || !pluginManifest.entryPoints || !pluginManifest.entryPoints.settings) {
|
||||
Logger.e("BarService", "Cannot open plugin settings: no settings entry point");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the popup menu window to use as parent
|
||||
var popupMenuWindow = PanelService.getPopupMenuWindow(screen);
|
||||
if (!popupMenuWindow) {
|
||||
Logger.e("BarService", "No popup menu window found for screen");
|
||||
return;
|
||||
}
|
||||
|
||||
// Close any existing dialogs first to prevent stacking
|
||||
closeExistingDialogs(popupMenuWindow);
|
||||
|
||||
var component = Qt.createComponent(Quickshell.shellDir + "/Widgets/NPluginSettingsPopup.qml");
|
||||
|
||||
function instantiateAndOpen() {
|
||||
var dialog = component.createObject(popupMenuWindow.dialogParent, {
|
||||
"showToastOnSave": true,
|
||||
"screen": screen
|
||||
});
|
||||
|
||||
if (dialog) {
|
||||
// Enable keyboard focus for the popup menu window when dialog is open
|
||||
popupMenuWindow.hasDialog = true;
|
||||
// Close the popup menu window when dialog closes
|
||||
dialog.closed.connect(() => {
|
||||
popupMenuWindow.hasDialog = false;
|
||||
popupMenuWindow.close();
|
||||
dialog.destroy();
|
||||
});
|
||||
// Show the popup menu window and open the dialog
|
||||
popupMenuWindow.open();
|
||||
dialog.openPluginSettings(pluginManifest);
|
||||
} else {
|
||||
Logger.e("BarService", "Failed to create plugin settings dialog");
|
||||
}
|
||||
}
|
||||
|
||||
if (component.status === Component.Ready) {
|
||||
instantiateAndOpen();
|
||||
} else if (component.status === Component.Error) {
|
||||
Logger.e("BarService", "Error loading plugin settings dialog:", component.errorString());
|
||||
} else {
|
||||
component.statusChanged.connect(function () {
|
||||
if (component.status === Component.Ready) {
|
||||
instantiateAndOpen();
|
||||
} else if (component.status === Component.Error) {
|
||||
Logger.e("BarService", "Error loading plugin settings dialog:", component.errorString());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Widgets
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
signal pluginWidgetRegistryUpdated
|
||||
|
||||
// Widget registry object mapping widget names to components
|
||||
property var widgets: ({
|
||||
"ActiveWindow": activeWindowComponent,
|
||||
"AudioVisualizer": audioVisualizerComponent,
|
||||
"Battery": batteryComponent,
|
||||
"Bluetooth": bluetoothComponent,
|
||||
"Brightness": brightnessComponent,
|
||||
"Clock": clockComponent,
|
||||
"ControlCenter": controlCenterComponent,
|
||||
"CustomButton": customButtonComponent,
|
||||
"DarkMode": darkModeComponent,
|
||||
"KeepAwake": keepAwakeComponent,
|
||||
"KeyboardLayout": keyboardLayoutComponent,
|
||||
"LockKeys": lockKeysComponent,
|
||||
"Launcher": launcherComponent,
|
||||
"MediaMini": mediaMiniComponent,
|
||||
"Microphone": microphoneComponent,
|
||||
"Network": networkComponent,
|
||||
"NightLight": nightLightComponent,
|
||||
"NoctaliaPerformance": noctaliaPerformanceComponent,
|
||||
"NotificationHistory": notificationHistoryComponent,
|
||||
"PowerProfile": powerProfileComponent,
|
||||
"SessionMenu": sessionMenuComponent,
|
||||
"Settings": settingsComponent,
|
||||
"Spacer": spacerComponent,
|
||||
"SystemMonitor": systemMonitorComponent,
|
||||
"Taskbar": taskbarComponent,
|
||||
"Tray": trayComponent,
|
||||
"Volume": volumeComponent,
|
||||
"VPN": vpnComponent,
|
||||
"WallpaperSelector": wallpaperSelectorComponent,
|
||||
"Workspace": workspaceComponent
|
||||
})
|
||||
|
||||
property var widgetSettingsMap: ({
|
||||
"ActiveWindow": "WidgetSettings/ActiveWindowSettings.qml",
|
||||
"AudioVisualizer": "WidgetSettings/AudioVisualizerSettings.qml",
|
||||
"Battery": "WidgetSettings/BatterySettings.qml",
|
||||
"Bluetooth": "WidgetSettings/BluetoothSettings.qml",
|
||||
"Brightness": "WidgetSettings/BrightnessSettings.qml",
|
||||
"Clock": "WidgetSettings/ClockSettings.qml",
|
||||
"ControlCenter": "WidgetSettings/ControlCenterSettings.qml",
|
||||
"CustomButton": "WidgetSettings/CustomButtonSettings.qml",
|
||||
"DarkMode": "WidgetSettings/DarkModeSettings.qml",
|
||||
"KeepAwake": "WidgetSettings/KeepAwakeSettings.qml",
|
||||
"KeyboardLayout": "WidgetSettings/KeyboardLayoutSettings.qml",
|
||||
"Launcher": "WidgetSettings/LauncherSettings.qml",
|
||||
"LockKeys": "WidgetSettings/LockKeysSettings.qml",
|
||||
"MediaMini": "WidgetSettings/MediaMiniSettings.qml",
|
||||
"Microphone": "WidgetSettings/MicrophoneSettings.qml",
|
||||
"Network": "WidgetSettings/NetworkSettings.qml",
|
||||
"NightLight": "WidgetSettings/NightLightSettings.qml",
|
||||
"NoctaliaPerformance": "WidgetSettings/NoctaliaPerformanceSettings.qml",
|
||||
"NotificationHistory": "WidgetSettings/NotificationHistorySettings.qml",
|
||||
"PowerProfile": "WidgetSettings/PowerProfileSettings.qml",
|
||||
"SessionMenu": "WidgetSettings/SessionMenuSettings.qml",
|
||||
"Settings": "WidgetSettings/SettingsSettings.qml",
|
||||
"Spacer": "WidgetSettings/SpacerSettings.qml",
|
||||
"SystemMonitor": "WidgetSettings/SystemMonitorSettings.qml",
|
||||
"Taskbar": "WidgetSettings/TaskbarSettings.qml",
|
||||
"Tray": "WidgetSettings/TraySettings.qml",
|
||||
"Volume": "WidgetSettings/VolumeSettings.qml",
|
||||
"VPN": "WidgetSettings/VPNSettings.qml",
|
||||
"WallpaperSelector": "WidgetSettings/WallpaperSelectorSettings.qml",
|
||||
"Workspace": "WidgetSettings/WorkspaceSettings.qml"
|
||||
})
|
||||
|
||||
property var widgetMetadata: ({
|
||||
"ActiveWindow": {
|
||||
"showText": true,
|
||||
"showIcon": true,
|
||||
"hideMode": "hidden",
|
||||
"scrollingMode": "hover",
|
||||
"maxWidth": 145,
|
||||
"useFixedWidth": false,
|
||||
"colorizeIcons": false,
|
||||
"textColor": "none"
|
||||
},
|
||||
"AudioVisualizer": {
|
||||
"width": 200,
|
||||
"colorName": "primary",
|
||||
"hideWhenIdle": false
|
||||
},
|
||||
"Battery": {
|
||||
"displayMode": "graphic-clean",
|
||||
"deviceNativePath": "__default__",
|
||||
"showPowerProfiles": false,
|
||||
"showNoctaliaPerformance": false,
|
||||
"hideIfNotDetected": true,
|
||||
"hideIfIdle": false
|
||||
},
|
||||
"Bluetooth": {
|
||||
"displayMode": "onhover",
|
||||
"iconColor": "none",
|
||||
"textColor": "none"
|
||||
},
|
||||
"Brightness": {
|
||||
"displayMode": "onhover",
|
||||
"iconColor": "none",
|
||||
"textColor": "none",
|
||||
"applyToAllMonitors": false
|
||||
},
|
||||
"Clock": {
|
||||
"clockColor": "none",
|
||||
"useCustomFont": false,
|
||||
"customFont": "",
|
||||
"formatHorizontal": "HH:mm ddd, MMM dd",
|
||||
"formatVertical": "HH mm - dd MM",
|
||||
"tooltipFormat": "HH:mm ddd, MMM dd"
|
||||
},
|
||||
"ControlCenter": {
|
||||
"useDistroLogo": false,
|
||||
"icon": "noctalia",
|
||||
"customIconPath": "",
|
||||
"colorizeDistroLogo": false,
|
||||
"colorizeSystemIcon": "none",
|
||||
"enableColorization": false
|
||||
},
|
||||
"CustomButton": {
|
||||
"icon": "heart",
|
||||
"showIcon": true,
|
||||
"showExecTooltip": true,
|
||||
"showTextTooltip": true,
|
||||
"generalTooltipText": "",
|
||||
"hideMode": "alwaysExpanded",
|
||||
"leftClickExec": "",
|
||||
"leftClickUpdateText": false,
|
||||
"rightClickExec": "",
|
||||
"rightClickUpdateText": false,
|
||||
"middleClickExec": "",
|
||||
"middleClickUpdateText": false,
|
||||
"textCommand": "",
|
||||
"textStream": false,
|
||||
"textIntervalMs": 3000,
|
||||
"textCollapse": "",
|
||||
"parseJson": false,
|
||||
"wheelExec": "",
|
||||
"wheelUpExec": "",
|
||||
"wheelDownExec": "",
|
||||
"wheelMode": "unified",
|
||||
"wheelUpdateText": false,
|
||||
"wheelUpUpdateText": false,
|
||||
"wheelDownUpdateText": false,
|
||||
"maxTextLength": {
|
||||
"horizontal": 10,
|
||||
"vertical": 10
|
||||
},
|
||||
"enableColorization": false,
|
||||
"colorizeSystemIcon": "none",
|
||||
"ipcIdentifier": ""
|
||||
},
|
||||
"DarkMode": {
|
||||
"iconColor": "none"
|
||||
},
|
||||
"KeepAwake": {
|
||||
"iconColor": "none",
|
||||
"textColor": "none"
|
||||
},
|
||||
"KeyboardLayout": {
|
||||
"displayMode": "onhover",
|
||||
"showIcon": true,
|
||||
"iconColor": "none",
|
||||
"textColor": "none"
|
||||
},
|
||||
"LockKeys": {
|
||||
"showCapsLock": true,
|
||||
"showNumLock": true,
|
||||
"showScrollLock": true,
|
||||
"capsLockIcon": "letter-c",
|
||||
"numLockIcon": "letter-n",
|
||||
"scrollLockIcon": "letter-s",
|
||||
"hideWhenOff": false
|
||||
},
|
||||
"Launcher": {
|
||||
"useDistroLogo": false,
|
||||
"icon": "rocket",
|
||||
"customIconPath": "",
|
||||
"colorizeSystemIcon": "none",
|
||||
"enableColorization": false,
|
||||
"iconColor": "none"
|
||||
},
|
||||
"MediaMini": {
|
||||
"hideMode": "hidden",
|
||||
"scrollingMode": "hover",
|
||||
"maxWidth": 145,
|
||||
"useFixedWidth": false,
|
||||
"hideWhenIdle": false,
|
||||
"showAlbumArt": true,
|
||||
"showArtistFirst": true,
|
||||
"showVisualizer": false,
|
||||
"showProgressRing": true,
|
||||
"visualizerType": "linear",
|
||||
"textColor": "none",
|
||||
"compactMode": false,
|
||||
"panelShowAlbumArt": true
|
||||
},
|
||||
"Microphone": {
|
||||
"displayMode": "onhover",
|
||||
"middleClickCommand": "pwvucontrol || pavucontrol",
|
||||
"iconColor": "none",
|
||||
"textColor": "none"
|
||||
},
|
||||
"NotificationHistory": {
|
||||
"showUnreadBadge": true,
|
||||
"hideWhenZero": false,
|
||||
"hideWhenZeroUnread": false,
|
||||
"unreadBadgeColor": "primary",
|
||||
"iconColor": "none"
|
||||
},
|
||||
"SessionMenu": {
|
||||
"iconColor": "error"
|
||||
},
|
||||
"Settings": {
|
||||
"iconColor": "none"
|
||||
},
|
||||
"Spacer": {
|
||||
"width": 20
|
||||
},
|
||||
"SystemMonitor": {
|
||||
"compactMode": true,
|
||||
"iconColor": "none",
|
||||
"textColor": "none",
|
||||
"useMonospaceFont": true,
|
||||
"usePadding": false,
|
||||
"showCpuUsage": true,
|
||||
"showCpuCores": false,
|
||||
"showCpuFreq": false,
|
||||
"showCpuTemp": true,
|
||||
"showGpuTemp": false,
|
||||
"showLoadAverage": false,
|
||||
"showMemoryUsage": true,
|
||||
"showMemoryAsPercent": false,
|
||||
"showSwapUsage": false,
|
||||
"showNetworkStats": false,
|
||||
"showDiskUsage": false,
|
||||
"showDiskUsageAsPercent": false,
|
||||
"showDiskAvailable": false,
|
||||
"diskPath": "/"
|
||||
},
|
||||
"Taskbar": {
|
||||
"onlySameOutput": true,
|
||||
"onlyActiveWorkspaces": true,
|
||||
"hideMode": "hidden",
|
||||
"colorizeIcons": false,
|
||||
"showTitle": false,
|
||||
"titleWidth": 120,
|
||||
"showPinnedApps": true,
|
||||
"smartWidth": true,
|
||||
"maxTaskbarWidth": 40,
|
||||
"iconScale": 0.8
|
||||
},
|
||||
"Tray": {
|
||||
"blacklist": [],
|
||||
"colorizeIcons": false,
|
||||
"chevronColor": "none",
|
||||
"pinned": [],
|
||||
"drawerEnabled": true,
|
||||
"hidePassive": false
|
||||
},
|
||||
"VPN": {
|
||||
"displayMode": "onhover",
|
||||
"iconColor": "none",
|
||||
"textColor": "none"
|
||||
},
|
||||
"Network": {
|
||||
"displayMode": "onhover",
|
||||
"iconColor": "none",
|
||||
"textColor": "none"
|
||||
},
|
||||
"NightLight": {
|
||||
"iconColor": "none"
|
||||
},
|
||||
"NoctaliaPerformance": {
|
||||
"iconColor": "none"
|
||||
},
|
||||
"PowerProfile": {
|
||||
"iconColor": "none"
|
||||
},
|
||||
"Workspace": {
|
||||
"labelMode": "index",
|
||||
"followFocusedScreen": false,
|
||||
"hideUnoccupied": false,
|
||||
"characterCount": 2,
|
||||
"showApplications": false,
|
||||
"showApplicationsHover": false,
|
||||
"showLabelsOnlyWhenOccupied": true,
|
||||
"colorizeIcons": false,
|
||||
"unfocusedIconsOpacity": 1.0,
|
||||
"groupedBorderOpacity": 1.0,
|
||||
"enableScrollWheel": true,
|
||||
"iconScale": 0.8,
|
||||
"focusedColor": "primary",
|
||||
"occupiedColor": "secondary",
|
||||
"emptyColor": "secondary",
|
||||
"showBadge": true,
|
||||
"pillSize": 0.6,
|
||||
"fontWeight": "bold"
|
||||
},
|
||||
"Volume": {
|
||||
"displayMode": "onhover",
|
||||
"middleClickCommand": "pwvucontrol || pavucontrol",
|
||||
"iconColor": "none",
|
||||
"textColor": "none"
|
||||
},
|
||||
"WallpaperSelector": {
|
||||
"iconColor": "none"
|
||||
}
|
||||
})
|
||||
|
||||
// Component definitions - these are loaded once at startup
|
||||
property Component activeWindowComponent: Component {
|
||||
ActiveWindow {}
|
||||
}
|
||||
property Component audioVisualizerComponent: Component {
|
||||
AudioVisualizer {}
|
||||
}
|
||||
property Component batteryComponent: Component {
|
||||
Battery {}
|
||||
}
|
||||
property Component bluetoothComponent: Component {
|
||||
Bluetooth {}
|
||||
}
|
||||
property Component brightnessComponent: Component {
|
||||
Brightness {}
|
||||
}
|
||||
property Component clockComponent: Component {
|
||||
Clock {}
|
||||
}
|
||||
property Component customButtonComponent: Component {
|
||||
CustomButton {}
|
||||
}
|
||||
property Component darkModeComponent: Component {
|
||||
DarkMode {}
|
||||
}
|
||||
property Component keyboardLayoutComponent: Component {
|
||||
KeyboardLayout {}
|
||||
}
|
||||
property Component keepAwakeComponent: Component {
|
||||
KeepAwake {}
|
||||
}
|
||||
property Component lockKeysComponent: Component {
|
||||
LockKeys {}
|
||||
}
|
||||
property Component launcherComponent: Component {
|
||||
Launcher {}
|
||||
}
|
||||
property Component mediaMiniComponent: Component {
|
||||
MediaMini {}
|
||||
}
|
||||
property Component microphoneComponent: Component {
|
||||
Microphone {}
|
||||
}
|
||||
property Component nightLightComponent: Component {
|
||||
NightLight {}
|
||||
}
|
||||
property Component noctaliaPerformanceComponent: Component {
|
||||
NoctaliaPerformance {}
|
||||
}
|
||||
property Component notificationHistoryComponent: Component {
|
||||
NotificationHistory {}
|
||||
}
|
||||
property Component powerProfileComponent: Component {
|
||||
PowerProfile {}
|
||||
}
|
||||
property Component sessionMenuComponent: Component {
|
||||
SessionMenu {}
|
||||
}
|
||||
property Component settingsComponent: Component {
|
||||
Settings {}
|
||||
}
|
||||
property Component controlCenterComponent: Component {
|
||||
ControlCenter {}
|
||||
}
|
||||
property Component spacerComponent: Component {
|
||||
Spacer {}
|
||||
}
|
||||
property Component systemMonitorComponent: Component {
|
||||
SystemMonitor {}
|
||||
}
|
||||
property Component trayComponent: Component {
|
||||
Tray {}
|
||||
}
|
||||
property Component volumeComponent: Component {
|
||||
Volume {}
|
||||
}
|
||||
property Component vpnComponent: Component {
|
||||
VPN {}
|
||||
}
|
||||
property Component networkComponent: Component {
|
||||
Network {}
|
||||
}
|
||||
property Component wallpaperSelectorComponent: Component {
|
||||
WallpaperSelector {}
|
||||
}
|
||||
property Component workspaceComponent: Component {
|
||||
Workspace {}
|
||||
}
|
||||
property Component taskbarComponent: Component {
|
||||
Taskbar {}
|
||||
}
|
||||
function init() {
|
||||
Logger.i("BarWidgetRegistry", "Service started");
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Helper function to get widget component by name
|
||||
function getWidget(id) {
|
||||
return widgets[id] || null;
|
||||
}
|
||||
|
||||
// Helper function to check if widget exists
|
||||
function hasWidget(id) {
|
||||
return id in widgets;
|
||||
}
|
||||
|
||||
// Get list of available widget id
|
||||
function getAvailableWidgets() {
|
||||
return Object.keys(widgets);
|
||||
}
|
||||
|
||||
// Helper function to check if widget has user settings
|
||||
function widgetHasUserSettings(id) {
|
||||
return widgetMetadata[id] !== undefined;
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Plugin widget registration
|
||||
|
||||
// Track plugin widgets separately
|
||||
property var pluginWidgets: ({})
|
||||
property var pluginWidgetMetadata: ({})
|
||||
|
||||
// Register a plugin widget
|
||||
function registerPluginWidget(pluginId, component, metadata) {
|
||||
if (!pluginId || !component) {
|
||||
Logger.e("BarWidgetRegistry", "Cannot register plugin widget: invalid parameters");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add plugin: prefix to avoid conflicts with core widgets
|
||||
var widgetId = "plugin:" + pluginId;
|
||||
|
||||
pluginWidgets[widgetId] = component;
|
||||
pluginWidgetMetadata[widgetId] = metadata || {};
|
||||
|
||||
// Also add to main widgets object for unified access
|
||||
widgets[widgetId] = component;
|
||||
widgetMetadata[widgetId] = metadata || {};
|
||||
|
||||
Logger.i("BarWidgetRegistry", "Registered plugin widget:", widgetId);
|
||||
root.pluginWidgetRegistryUpdated();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Unregister a plugin widget
|
||||
function unregisterPluginWidget(pluginId) {
|
||||
var widgetId = "plugin:" + pluginId;
|
||||
|
||||
if (!pluginWidgets[widgetId]) {
|
||||
Logger.w("BarWidgetRegistry", "Plugin widget not registered:", widgetId);
|
||||
return false;
|
||||
}
|
||||
|
||||
delete pluginWidgets[widgetId];
|
||||
delete pluginWidgetMetadata[widgetId];
|
||||
delete widgets[widgetId];
|
||||
delete widgetMetadata[widgetId];
|
||||
|
||||
Logger.i("BarWidgetRegistry", "Unregistered plugin widget:", widgetId);
|
||||
root.pluginWidgetRegistryUpdated();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if a widget is a plugin widget
|
||||
function isPluginWidget(id) {
|
||||
return id.startsWith("plugin:");
|
||||
}
|
||||
|
||||
property var cpuIntensiveWidgets: ["AudioVisualizer"]
|
||||
|
||||
function isCpuIntensive(id) {
|
||||
if (pluginWidgetMetadata[id]?.cpuIntensive)
|
||||
return true;
|
||||
return cpuIntensiveWidgets.indexOf(id) >= 0;
|
||||
}
|
||||
|
||||
// Get list of plugin widget IDs
|
||||
function getPluginWidgets() {
|
||||
return Object.keys(pluginWidgets);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.Panels.ControlCenter.Widgets
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Widget registry object mapping widget names to components
|
||||
property var widgets: ({
|
||||
"AirplaneMode": airplaneModeComponent,
|
||||
"Bluetooth": bluetoothComponent,
|
||||
"CustomButton": customButtonComponent,
|
||||
"DarkMode": darkModeComponent,
|
||||
"KeepAwake": keepAwakeComponent,
|
||||
"NightLight": nightLightComponent,
|
||||
"Notifications": notificationsComponent,
|
||||
"PowerProfile": powerProfileComponent,
|
||||
"WiFi": networkComponent,
|
||||
"Network": networkComponent,
|
||||
"NoctaliaPerformance": noctaliaPerformanceComponent,
|
||||
"WallpaperSelector": wallpaperSelectorComponent
|
||||
})
|
||||
|
||||
property var widgetMetadata: ({
|
||||
"CustomButton": {
|
||||
"icon": "heart",
|
||||
"onClicked": "",
|
||||
"onRightClicked": "",
|
||||
"onMiddleClicked": "",
|
||||
"stateChecksJson": "[]",
|
||||
"generalTooltipText": "",
|
||||
"enableOnStateLogic": false,
|
||||
"showExecTooltip": true
|
||||
}
|
||||
})
|
||||
|
||||
property var cpuIntensiveWidgets: ["SystemStat"]
|
||||
|
||||
// Component definitions - these are loaded once at startup
|
||||
property Component airplaneModeComponent: Component {
|
||||
AirplaneMode {}
|
||||
}
|
||||
property Component bluetoothComponent: Component {
|
||||
Bluetooth {}
|
||||
}
|
||||
property Component customButtonComponent: Component {
|
||||
CustomButton {}
|
||||
}
|
||||
property Component darkModeComponent: Component {
|
||||
DarkMode {}
|
||||
}
|
||||
property Component keepAwakeComponent: Component {
|
||||
KeepAwake {}
|
||||
}
|
||||
property Component nightLightComponent: Component {
|
||||
NightLight {}
|
||||
}
|
||||
property Component notificationsComponent: Component {
|
||||
Notifications {}
|
||||
}
|
||||
property Component powerProfileComponent: Component {
|
||||
PowerProfile {}
|
||||
}
|
||||
property Component networkComponent: Component {
|
||||
Network {}
|
||||
}
|
||||
property Component noctaliaPerformanceComponent: Component {
|
||||
NoctaliaPerformance {}
|
||||
}
|
||||
property Component wallpaperSelectorComponent: Component {
|
||||
WallpaperSelector {}
|
||||
}
|
||||
|
||||
function init() {
|
||||
Logger.i("ControlCenterWidgetRegistry", "Service started");
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Helper function to get widget component by name
|
||||
function getWidget(id) {
|
||||
return widgets[id] || null;
|
||||
}
|
||||
|
||||
// Helper function to check if widget exists
|
||||
function hasWidget(id) {
|
||||
return id in widgets;
|
||||
}
|
||||
|
||||
// Get list of available widget id
|
||||
function getAvailableWidgets() {
|
||||
return Object.keys(widgets);
|
||||
}
|
||||
|
||||
// Helper function to check if widget has user settings
|
||||
function widgetHasUserSettings(id) {
|
||||
return widgetMetadata[id] !== undefined;
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Plugin widget registration
|
||||
|
||||
// Track plugin widgets separately
|
||||
property var pluginWidgets: ({})
|
||||
property var pluginWidgetMetadata: ({})
|
||||
|
||||
// Register a plugin widget
|
||||
function registerPluginWidget(pluginId, component, metadata) {
|
||||
if (!pluginId || !component) {
|
||||
Logger.e("ControlCenterWidgetRegistry", "Cannot register plugin widget: invalid parameters");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add plugin: prefix to avoid conflicts with core widgets
|
||||
var widgetId = "plugin:" + pluginId;
|
||||
|
||||
pluginWidgets[widgetId] = component;
|
||||
pluginWidgetMetadata[widgetId] = metadata || {};
|
||||
|
||||
// Also add to main widgets object for unified access
|
||||
widgets[widgetId] = component;
|
||||
widgetMetadata[widgetId] = metadata || {};
|
||||
|
||||
Logger.i("ControlCenterWidgetRegistry", "Registered plugin widget:", widgetId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Unregister a plugin widget
|
||||
function unregisterPluginWidget(pluginId) {
|
||||
var widgetId = "plugin:" + pluginId;
|
||||
|
||||
if (!pluginWidgets[widgetId]) {
|
||||
Logger.w("ControlCenterWidgetRegistry", "Plugin widget not registered:", widgetId);
|
||||
return false;
|
||||
}
|
||||
|
||||
delete pluginWidgets[widgetId];
|
||||
delete pluginWidgetMetadata[widgetId];
|
||||
delete widgets[widgetId];
|
||||
delete widgetMetadata[widgetId];
|
||||
|
||||
Logger.i("ControlCenterWidgetRegistry", "Unregistered plugin widget:", widgetId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if a widget is a plugin widget
|
||||
function isPluginWidget(id) {
|
||||
return id.startsWith("plugin:");
|
||||
}
|
||||
|
||||
// Get list of plugin widget IDs
|
||||
function getPluginWidgets() {
|
||||
return Object.keys(pluginWidgets);
|
||||
}
|
||||
|
||||
function isCpuIntensive(id) {
|
||||
if (pluginWidgetMetadata[id]?.cpuIntensive)
|
||||
return true;
|
||||
return cpuIntensiveWidgets.indexOf(id) >= 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.DesktopWidgets.Widgets
|
||||
import qs.Services.Noctalia
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Transient state - not persisted, resets on shell restart
|
||||
property bool editMode: false
|
||||
|
||||
// Signal emitted when plugin widgets are registered/unregistered
|
||||
signal pluginWidgetRegistryUpdated
|
||||
|
||||
// Component definitions
|
||||
property Component clockComponent: Component {
|
||||
DesktopClock {}
|
||||
}
|
||||
property Component mediaPlayerComponent: Component {
|
||||
DesktopMediaPlayer {}
|
||||
}
|
||||
property Component weatherComponent: Component {
|
||||
DesktopWeather {}
|
||||
}
|
||||
property Component systemStatComponent: Component {
|
||||
DesktopSystemStat {}
|
||||
}
|
||||
property Component audioVisualizerComponent: Component {
|
||||
DesktopAudioVisualizer {}
|
||||
}
|
||||
|
||||
// Widget registry object mapping widget names to components
|
||||
// Created in Component.onCompleted to ensure Components are ready
|
||||
property var widgets: ({})
|
||||
|
||||
// Explicit URLs for core widgets. Inline Component.url returns the parent
|
||||
// file's URL, so we need these for Loader.setSource() with initial properties.
|
||||
// Plugin widgets use their Component.url directly (loaded from actual files).
|
||||
readonly property string _widgetsDir: Quickshell.shellDir + "/Modules/DesktopWidgets/Widgets/"
|
||||
property var widgetUrls: ({})
|
||||
|
||||
Component.onCompleted: {
|
||||
// Initialize widgets object after Components are ready
|
||||
var widgetsObj = {};
|
||||
widgetsObj["Clock"] = clockComponent;
|
||||
widgetsObj["MediaPlayer"] = mediaPlayerComponent;
|
||||
widgetsObj["Weather"] = weatherComponent;
|
||||
widgetsObj["SystemStat"] = systemStatComponent;
|
||||
widgetsObj["AudioVisualizer"] = audioVisualizerComponent;
|
||||
widgets = widgetsObj;
|
||||
|
||||
var urlsObj = {};
|
||||
urlsObj["Clock"] = _widgetsDir + "DesktopClock.qml";
|
||||
urlsObj["MediaPlayer"] = _widgetsDir + "DesktopMediaPlayer.qml";
|
||||
urlsObj["Weather"] = _widgetsDir + "DesktopWeather.qml";
|
||||
urlsObj["SystemStat"] = _widgetsDir + "DesktopSystemStat.qml";
|
||||
urlsObj["AudioVisualizer"] = _widgetsDir + "DesktopAudioVisualizer.qml";
|
||||
widgetUrls = urlsObj;
|
||||
|
||||
Logger.i("DesktopWidgetRegistry", "Service started");
|
||||
}
|
||||
|
||||
property var widgetSettingsMap: ({
|
||||
"Clock": "WidgetSettings/ClockSettings.qml",
|
||||
"MediaPlayer": "WidgetSettings/MediaPlayerSettings.qml",
|
||||
"Weather": "WidgetSettings/WeatherSettings.qml",
|
||||
"SystemStat": "WidgetSettings/SystemStatSettings.qml",
|
||||
"AudioVisualizer": "WidgetSettings/AudioVisualizerSettings.qml"
|
||||
})
|
||||
|
||||
property var widgetMetadata: ({
|
||||
"Clock": {
|
||||
"showBackground": true,
|
||||
"roundedCorners": true,
|
||||
"clockStyle": "digital",
|
||||
"clockColor": "none",
|
||||
"useCustomFont": false,
|
||||
"customFont": "",
|
||||
"format": "HH:mm\\nd MMMM yyyy"
|
||||
},
|
||||
"MediaPlayer": {
|
||||
"showBackground": true,
|
||||
"roundedCorners": true,
|
||||
"visualizerType": "linear",
|
||||
"hideMode": "visible",
|
||||
"showButtons": true,
|
||||
"showAlbumArt": true,
|
||||
"showVisualizer": true
|
||||
},
|
||||
"Weather": {
|
||||
"showBackground": true,
|
||||
"roundedCorners": true
|
||||
},
|
||||
"SystemStat": {
|
||||
"showBackground": true,
|
||||
"roundedCorners": true,
|
||||
"statType": "CPU",
|
||||
"diskPath": "/",
|
||||
"layout": "bottom"
|
||||
},
|
||||
"AudioVisualizer": {
|
||||
"showBackground": true,
|
||||
"roundedCorners": true,
|
||||
"width": 320,
|
||||
"height": 72,
|
||||
"visualizerType": "linear",
|
||||
"hideWhenIdle": false,
|
||||
"colorName": "primary"
|
||||
}
|
||||
})
|
||||
|
||||
property var cpuIntensiveWidgets: ["SystemStat", "AudioVisualizer"]
|
||||
|
||||
// Plugin widget storage (mirroring BarWidgetRegistry pattern)
|
||||
property var pluginWidgets: ({})
|
||||
property var pluginWidgetMetadata: ({})
|
||||
|
||||
function init() {
|
||||
Logger.i("DesktopWidgetRegistry", "Service started");
|
||||
}
|
||||
|
||||
// Helper function to get widget component by name
|
||||
function getWidget(id) {
|
||||
return widgets[id] || null;
|
||||
}
|
||||
|
||||
// Helper function to check if widget exists
|
||||
function hasWidget(id) {
|
||||
return id in widgets;
|
||||
}
|
||||
|
||||
// Get list of available widget ids
|
||||
function getAvailableWidgets() {
|
||||
var keys = Object.keys(widgets);
|
||||
Logger.d("DesktopWidgetRegistry", "getAvailableWidgets() called, returning:", keys);
|
||||
return keys;
|
||||
}
|
||||
|
||||
// Helper function to check if widget has user settings
|
||||
function widgetHasUserSettings(id) {
|
||||
return widgetMetadata[id] !== undefined;
|
||||
}
|
||||
|
||||
function isCpuIntensive(id) {
|
||||
if (pluginWidgetMetadata[id]?.cpuIntensive)
|
||||
return true;
|
||||
return cpuIntensiveWidgets.indexOf(id) >= 0;
|
||||
}
|
||||
|
||||
// Check if a widget is a plugin widget
|
||||
function isPluginWidget(id) {
|
||||
return id.startsWith("plugin:");
|
||||
}
|
||||
|
||||
// Get list of plugin widget IDs
|
||||
function getPluginWidgets() {
|
||||
return Object.keys(pluginWidgets);
|
||||
}
|
||||
|
||||
// Get display name for a widget ID
|
||||
function getWidgetDisplayName(widgetId) {
|
||||
if (widgetId.startsWith("plugin:")) {
|
||||
var pluginId = widgetId.replace("plugin:", "");
|
||||
var manifest = PluginRegistry.getPluginManifest(pluginId);
|
||||
return manifest ? manifest.name : pluginId;
|
||||
}
|
||||
// Core widgets - return as-is (Clock, MediaPlayer, Weather, SystemStat)
|
||||
return widgetId;
|
||||
}
|
||||
|
||||
// Register a plugin desktop widget
|
||||
function registerPluginWidget(pluginId, component, metadata) {
|
||||
if (!pluginId || !component) {
|
||||
Logger.e("DesktopWidgetRegistry", "Cannot register plugin widget: invalid parameters");
|
||||
return false;
|
||||
}
|
||||
|
||||
var widgetId = "plugin:" + pluginId;
|
||||
|
||||
// Create new objects to trigger QML property change detection
|
||||
var newPluginWidgets = Object.assign({}, pluginWidgets);
|
||||
newPluginWidgets[widgetId] = component;
|
||||
pluginWidgets = newPluginWidgets;
|
||||
|
||||
var newPluginMetadata = Object.assign({}, pluginWidgetMetadata);
|
||||
newPluginMetadata[widgetId] = metadata || {};
|
||||
pluginWidgetMetadata = newPluginMetadata;
|
||||
|
||||
// Also add to main widgets object for unified access - reassign to trigger change
|
||||
var newWidgets = Object.assign({}, widgets);
|
||||
newWidgets[widgetId] = component;
|
||||
widgets = newWidgets;
|
||||
|
||||
var newMetadata = Object.assign({}, widgetMetadata);
|
||||
newMetadata[widgetId] = Object.assign({}, {
|
||||
"showBackground": true
|
||||
}, metadata || {});
|
||||
widgetMetadata = newMetadata;
|
||||
|
||||
Logger.i("DesktopWidgetRegistry", "Registered plugin widget:", widgetId);
|
||||
root.pluginWidgetRegistryUpdated();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Unregister a plugin desktop widget
|
||||
function unregisterPluginWidget(pluginId) {
|
||||
var widgetId = "plugin:" + pluginId;
|
||||
|
||||
if (!pluginWidgets[widgetId]) {
|
||||
Logger.w("DesktopWidgetRegistry", "Plugin widget not registered:", widgetId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create new objects without the widget to trigger QML property change detection
|
||||
var newPluginWidgets = Object.assign({}, pluginWidgets);
|
||||
delete newPluginWidgets[widgetId];
|
||||
pluginWidgets = newPluginWidgets;
|
||||
|
||||
var newPluginMetadata = Object.assign({}, pluginWidgetMetadata);
|
||||
delete newPluginMetadata[widgetId];
|
||||
pluginWidgetMetadata = newPluginMetadata;
|
||||
|
||||
var newWidgets = Object.assign({}, widgets);
|
||||
delete newWidgets[widgetId];
|
||||
widgets = newWidgets;
|
||||
|
||||
var newMetadata = Object.assign({}, widgetMetadata);
|
||||
delete newMetadata[widgetId];
|
||||
widgetMetadata = newMetadata;
|
||||
|
||||
Logger.i("DesktopWidgetRegistry", "Unregistered plugin widget:", widgetId);
|
||||
root.pluginWidgetRegistryUpdated();
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateWidgetData(monitorName, widgetIndex, properties) {
|
||||
if (widgetIndex < 0 || !monitorName) {
|
||||
return;
|
||||
}
|
||||
|
||||
var monitorWidgets = Settings.data.desktopWidgets.monitorWidgets || [];
|
||||
var newMonitorWidgets = monitorWidgets.slice();
|
||||
|
||||
for (var i = 0; i < newMonitorWidgets.length; i++) {
|
||||
if (newMonitorWidgets[i].name === monitorName) {
|
||||
var widgets = (newMonitorWidgets[i].widgets || []).slice();
|
||||
if (widgetIndex < widgets.length) {
|
||||
widgets[widgetIndex] = Object.assign({}, widgets[widgetIndex], properties);
|
||||
newMonitorWidgets[i] = Object.assign({}, newMonitorWidgets[i], {
|
||||
"widgets": widgets
|
||||
});
|
||||
Settings.data.desktopWidgets.monitorWidgets = newMonitorWidgets;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
property var currentSettingsDialog: null
|
||||
|
||||
function openWidgetSettings(screen, widgetIndex, widgetId, widgetData) {
|
||||
if (!widgetId || !screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.currentSettingsDialog) {
|
||||
root.currentSettingsDialog.close();
|
||||
root.currentSettingsDialog.destroy();
|
||||
root.currentSettingsDialog = null;
|
||||
}
|
||||
|
||||
var hasSettings = false;
|
||||
if (root.isPluginWidget(widgetId)) {
|
||||
var pluginId = widgetId.replace("plugin:", "");
|
||||
var manifest = PluginRegistry.getPluginManifest(pluginId);
|
||||
if (manifest && manifest.entryPoints && (manifest.entryPoints.desktopWidgetSettings || manifest.entryPoints.settings)) {
|
||||
hasSettings = true;
|
||||
}
|
||||
} else {
|
||||
hasSettings = root.widgetSettingsMap[widgetId] !== undefined;
|
||||
}
|
||||
|
||||
if (!hasSettings) {
|
||||
Logger.w("DesktopWidgetRegistry", "Widget does not have settings:", widgetId);
|
||||
return;
|
||||
}
|
||||
|
||||
var popupMenuWindow = PanelService.getPopupMenuWindow(screen);
|
||||
if (!popupMenuWindow) {
|
||||
Logger.e("DesktopWidgetRegistry", "No popup menu window found for screen");
|
||||
return;
|
||||
}
|
||||
|
||||
if (popupMenuWindow.hideDynamicMenu) {
|
||||
popupMenuWindow.hideDynamicMenu();
|
||||
}
|
||||
|
||||
var component = Qt.createComponent(Quickshell.shellDir + "/Modules/Panels/Settings/DesktopWidgets/DesktopWidgetSettingsDialog.qml");
|
||||
|
||||
function instantiateAndOpen() {
|
||||
var dialog = component.createObject(popupMenuWindow.dialogParent, {
|
||||
"widgetIndex": widgetIndex,
|
||||
"widgetData": widgetData,
|
||||
"widgetId": widgetId,
|
||||
"sectionId": screen.name,
|
||||
"screen": screen
|
||||
});
|
||||
|
||||
if (dialog) {
|
||||
root.currentSettingsDialog = dialog;
|
||||
dialog.updateWidgetSettings.connect((sec, idx, settings) => {
|
||||
root.updateWidgetData(sec, idx, settings);
|
||||
});
|
||||
popupMenuWindow.hasDialog = true;
|
||||
dialog.closed.connect(() => {
|
||||
popupMenuWindow.hasDialog = false;
|
||||
popupMenuWindow.close();
|
||||
if (root.currentSettingsDialog === dialog) {
|
||||
root.currentSettingsDialog = null;
|
||||
}
|
||||
dialog.destroy();
|
||||
});
|
||||
dialog.open();
|
||||
} else {
|
||||
Logger.e("DesktopWidgetRegistry", "Failed to create widget settings dialog");
|
||||
}
|
||||
}
|
||||
|
||||
if (component.status === Component.Ready) {
|
||||
instantiateAndOpen();
|
||||
} else if (component.status === Component.Error) {
|
||||
Logger.e("DesktopWidgetRegistry", "Error loading settings dialog component:", component.errorString());
|
||||
} else {
|
||||
component.statusChanged.connect(() => {
|
||||
if (component.status === Component.Ready) {
|
||||
instantiateAndOpen();
|
||||
} else if (component.status === Component.Error) {
|
||||
Logger.e("DesktopWidgetRegistry", "Error loading settings dialog component:", component.errorString());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import "../../Helpers/sha256.js" as Checksum
|
||||
import qs.Commons
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// -------------------------------------------------
|
||||
// Public Properties
|
||||
// -------------------------------------------------
|
||||
property bool imageMagickAvailable: false
|
||||
property bool initialized: false
|
||||
|
||||
// Cache directories
|
||||
readonly property string baseDir: Settings.cacheDir + "images/"
|
||||
readonly property string wpThumbDir: baseDir + "wallpapers/thumbnails/"
|
||||
readonly property string wpLargeDir: baseDir + "wallpapers/large/"
|
||||
readonly property string notificationsDir: baseDir + "notifications/"
|
||||
readonly property string contributorsDir: baseDir + "contributors/"
|
||||
|
||||
// Supported image formats - extended list when ImageMagick is available
|
||||
readonly property var basicImageFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.bmp"]
|
||||
readonly property var extendedImageFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.bmp", "*.webp", "*.avif", "*.heic", "*.heif", "*.tiff", "*.tif", "*.pnm", "*.pgm", "*.ppm", "*.pbm", "*.svg", "*.svgz", "*.ico", "*.icns", "*.jxl", "*.jp2", "*.j2k", "*.exr", "*.hdr", "*.dds", "*.tga"]
|
||||
readonly property var imageFilters: imageMagickAvailable ? extendedImageFilters : basicImageFilters
|
||||
|
||||
// Check if a file format needs conversion (not natively supported by Qt)
|
||||
function needsConversion(filePath) {
|
||||
const ext = "*." + filePath.toLowerCase().split('.').pop();
|
||||
return !basicImageFilters.includes(ext);
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Internal State
|
||||
// -------------------------------------------------
|
||||
property var pendingRequests: ({})
|
||||
property var fallbackQueue: []
|
||||
property bool fallbackProcessing: false
|
||||
|
||||
// Process queues to prevent "too many open files" errors
|
||||
property var utilityProcessQueue: []
|
||||
property int runningUtilityProcesses: 0
|
||||
readonly property int maxConcurrentUtilityProcesses: 16
|
||||
|
||||
// Separate queue for heavy ImageMagick processing (lower concurrency)
|
||||
property var imageMagickQueue: []
|
||||
property int runningImageMagickProcesses: 0
|
||||
readonly property int maxConcurrentImageMagickProcesses: 4
|
||||
|
||||
// -------------------------------------------------
|
||||
// Signals
|
||||
// -------------------------------------------------
|
||||
signal cacheHit(string cacheKey, string cachedPath)
|
||||
signal cacheMiss(string cacheKey)
|
||||
signal processingComplete(string cacheKey, string cachedPath)
|
||||
signal processingFailed(string cacheKey, string error)
|
||||
|
||||
// -------------------------------------------------
|
||||
// Initialization
|
||||
// -------------------------------------------------
|
||||
function init() {
|
||||
Logger.i("ImageCache", "Service started");
|
||||
createDirectories();
|
||||
cleanupOldCache();
|
||||
checkMagickProcess.running = true;
|
||||
}
|
||||
|
||||
function createDirectories() {
|
||||
Quickshell.execDetached(["mkdir", "-p", wpThumbDir]);
|
||||
Quickshell.execDetached(["mkdir", "-p", wpLargeDir]);
|
||||
Quickshell.execDetached(["mkdir", "-p", notificationsDir]);
|
||||
Quickshell.execDetached(["mkdir", "-p", contributorsDir]);
|
||||
}
|
||||
|
||||
function cleanupOldCache() {
|
||||
const dirs = [wpThumbDir, wpLargeDir, notificationsDir, contributorsDir];
|
||||
dirs.forEach(function (dir) {
|
||||
Quickshell.execDetached(["find", dir, "-type", "f", "-mtime", "+15", "-delete"]);
|
||||
});
|
||||
Logger.d("ImageCache", "Cleanup triggered for files older than 15 days");
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Public API: Get Thumbnail (384x384)
|
||||
// -------------------------------------------------
|
||||
function getThumbnail(sourcePath, callback) {
|
||||
if (!sourcePath || sourcePath === "") {
|
||||
callback("", false);
|
||||
return;
|
||||
}
|
||||
|
||||
getMtime(sourcePath, function (mtime) {
|
||||
const cacheKey = generateThumbnailKey(sourcePath, mtime);
|
||||
const cachedPath = wpThumbDir + cacheKey + ".png";
|
||||
|
||||
processRequest(cacheKey, cachedPath, sourcePath, callback, function () {
|
||||
if (imageMagickAvailable) {
|
||||
startThumbnailProcessing(sourcePath, cachedPath, cacheKey);
|
||||
} else {
|
||||
queueFallbackProcessing(sourcePath, cachedPath, cacheKey, 384);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Public API: Get Large Image (scaled to specified dimensions)
|
||||
// -------------------------------------------------
|
||||
function getLarge(sourcePath, width, height, callback) {
|
||||
if (!sourcePath || sourcePath === "") {
|
||||
callback("", false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Settings.data.wallpaper.useOriginalImages && !needsConversion(sourcePath)) {
|
||||
callback(sourcePath, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!imageMagickAvailable) {
|
||||
Logger.d("ImageCache", "ImageMagick not available, using original:", sourcePath);
|
||||
callback(sourcePath, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast dimension check - skip processing if image fits screen AND format is Qt-native
|
||||
getImageDimensions(sourcePath, function (imgWidth, imgHeight) {
|
||||
const fitsScreen = imgWidth > 0 && imgHeight > 0 && imgWidth <= width && imgHeight <= height;
|
||||
|
||||
if (fitsScreen) {
|
||||
// Only skip if format is natively supported by Qt
|
||||
if (!needsConversion(sourcePath)) {
|
||||
Logger.d("ImageCache", `Image ${imgWidth}x${imgHeight} fits screen ${width}x${height}, using original`);
|
||||
callback(sourcePath, false);
|
||||
return;
|
||||
}
|
||||
Logger.d("ImageCache", `Image needs conversion despite fitting screen`);
|
||||
}
|
||||
|
||||
// Use actual image dimensions if it fits (convert without upscaling), otherwise use screen dimensions
|
||||
const targetWidth = fitsScreen ? imgWidth : width;
|
||||
const targetHeight = fitsScreen ? imgHeight : height;
|
||||
|
||||
getMtime(sourcePath, function (mtime) {
|
||||
const cacheKey = generateLargeKey(sourcePath, width, height, mtime);
|
||||
const cachedPath = wpLargeDir + cacheKey + ".png";
|
||||
|
||||
processRequest(cacheKey, cachedPath, sourcePath, callback, function () {
|
||||
startLargeProcessing(sourcePath, cachedPath, targetWidth, targetHeight, cacheKey);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Public API: Get Notification Icon (64x64)
|
||||
// -------------------------------------------------
|
||||
function getNotificationIcon(imageUri, appName, summary, callback) {
|
||||
if (!imageUri || imageUri === "") {
|
||||
callback("", false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve bare file path for temp check
|
||||
const filePath = imageUri.startsWith("file://") ? imageUri.substring(7) : imageUri;
|
||||
|
||||
// File paths in persistent locations are used directly, not cached
|
||||
if ((imageUri.startsWith("/") || imageUri.startsWith("file://")) && !isTemporaryPath(filePath)) {
|
||||
callback(imageUri, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = generateNotificationKey(imageUri, appName, summary);
|
||||
const cachedPath = notificationsDir + cacheKey + ".png";
|
||||
|
||||
// Temporary file paths are copied to cache before the source is cleaned up
|
||||
if (imageUri.startsWith("/") || imageUri.startsWith("file://")) {
|
||||
processRequest(cacheKey, cachedPath, imageUri, callback, function () {
|
||||
copyTempFileToCache(filePath, cachedPath, cacheKey);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
processRequest(cacheKey, cachedPath, imageUri, callback, function () {
|
||||
// Notifications always use Qt fallback (image:// URIs can't be read by ImageMagick)
|
||||
queueFallbackProcessing(imageUri, cachedPath, cacheKey, 64);
|
||||
});
|
||||
}
|
||||
|
||||
// Check if a path is in a temporary directory that may be cleaned up
|
||||
function isTemporaryPath(path) {
|
||||
return path.startsWith("/tmp/");
|
||||
}
|
||||
|
||||
// Copy a temporary file to the cache directory
|
||||
function copyTempFileToCache(sourcePath, destPath, cacheKey) {
|
||||
const srcEsc = sourcePath.replace(/'/g, "'\\''");
|
||||
const dstEsc = destPath.replace(/'/g, "'\\''");
|
||||
|
||||
const processString = `
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
Process {
|
||||
command: ["cp", "--", "${srcEsc}", "${dstEsc}"]
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {}
|
||||
}
|
||||
`;
|
||||
|
||||
queueUtilityProcess({
|
||||
name: "CopyTempFile_" + cacheKey,
|
||||
processString: processString,
|
||||
onComplete: function (exitCode) {
|
||||
if (exitCode === 0) {
|
||||
Logger.d("ImageCache", "Temp file cached:", destPath);
|
||||
notifyCallbacks(cacheKey, destPath, true);
|
||||
} else {
|
||||
Logger.w("ImageCache", "Failed to cache temp file:", sourcePath);
|
||||
notifyCallbacks(cacheKey, "", false);
|
||||
}
|
||||
},
|
||||
onError: function () {
|
||||
Logger.e("ImageCache", "Error caching temp file:", sourcePath);
|
||||
notifyCallbacks(cacheKey, "", false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Public API: Get Circular Avatar (256x256)
|
||||
// -------------------------------------------------
|
||||
function getCircularAvatar(url, username, callback) {
|
||||
if (!url || !username) {
|
||||
callback("", false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = username;
|
||||
const cachedPath = contributorsDir + username + "_circular.png";
|
||||
|
||||
processRequest(cacheKey, cachedPath, url, callback, function () {
|
||||
if (imageMagickAvailable) {
|
||||
downloadAndProcessAvatar(url, username, cachedPath, cacheKey);
|
||||
} else {
|
||||
// No fallback for circular avatars without ImageMagick
|
||||
Logger.w("ImageCache", "Circular avatars require ImageMagick");
|
||||
notifyCallbacks(cacheKey, "", false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Cache Key Generation
|
||||
// -------------------------------------------------
|
||||
function generateThumbnailKey(sourcePath, mtime) {
|
||||
const keyString = sourcePath + "@384x384@" + (mtime || "unknown");
|
||||
return Checksum.sha256(keyString);
|
||||
}
|
||||
|
||||
function generateLargeKey(sourcePath, width, height, mtime) {
|
||||
const keyString = sourcePath + "@" + width + "x" + height + "@" + (mtime || "unknown");
|
||||
return Checksum.sha256(keyString);
|
||||
}
|
||||
|
||||
function generateNotificationKey(imageUri, appName, summary) {
|
||||
if (imageUri.startsWith("image://qsimage/")) {
|
||||
return Checksum.sha256(appName + "|" + summary);
|
||||
}
|
||||
return Checksum.sha256(imageUri);
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Request Processing (with coalescing)
|
||||
// -------------------------------------------------
|
||||
function processRequest(cacheKey, cachedPath, sourcePath, callback, processFn) {
|
||||
// Check if already processing this request
|
||||
if (pendingRequests[cacheKey]) {
|
||||
pendingRequests[cacheKey].callbacks.push(callback);
|
||||
Logger.d("ImageCache", "Coalescing request for:", cacheKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
checkFileExists(cachedPath, function (exists) {
|
||||
if (exists) {
|
||||
Logger.d("ImageCache", "Cache hit:", cachedPath);
|
||||
callback(cachedPath, true);
|
||||
cacheHit(cacheKey, cachedPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-check pendingRequests (race condition fix)
|
||||
if (pendingRequests[cacheKey]) {
|
||||
pendingRequests[cacheKey].callbacks.push(callback);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start new processing
|
||||
Logger.d("ImageCache", "Cache miss, processing:", sourcePath);
|
||||
cacheMiss(cacheKey);
|
||||
pendingRequests[cacheKey] = {
|
||||
callbacks: [callback],
|
||||
sourcePath: sourcePath
|
||||
};
|
||||
|
||||
processFn();
|
||||
});
|
||||
}
|
||||
|
||||
function notifyCallbacks(cacheKey, path, success) {
|
||||
const request = pendingRequests[cacheKey];
|
||||
if (request) {
|
||||
request.callbacks.forEach(function (cb) {
|
||||
cb(path, success);
|
||||
});
|
||||
delete pendingRequests[cacheKey];
|
||||
}
|
||||
|
||||
if (success) {
|
||||
processingComplete(cacheKey, path);
|
||||
} else {
|
||||
processingFailed(cacheKey, "Processing failed");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// ImageMagick Processing: Thumbnail
|
||||
// -------------------------------------------------
|
||||
function startThumbnailProcessing(sourcePath, outputPath, cacheKey) {
|
||||
const srcEsc = sourcePath.replace(/'/g, "'\\''");
|
||||
const dstEsc = outputPath.replace(/'/g, "'\\''");
|
||||
|
||||
// Use Lanczos filter for high-quality downscaling, subtle unsharp mask, and PNG for lossless output
|
||||
const command = `magick '${srcEsc}' -auto-orient -filter Lanczos -resize '384x384^' -gravity center -extent 384x384 -unsharp 0x0.5 '${dstEsc}'`;
|
||||
|
||||
runProcess(command, cacheKey, outputPath, sourcePath);
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// ImageMagick Processing: Large
|
||||
// -------------------------------------------------
|
||||
function startLargeProcessing(sourcePath, outputPath, width, height, cacheKey) {
|
||||
const srcEsc = sourcePath.replace(/'/g, "'\\''");
|
||||
const dstEsc = outputPath.replace(/'/g, "'\\''");
|
||||
|
||||
// Use Lanczos filter for high-quality downscaling and PNG for lossless output
|
||||
const command = `magick '${srcEsc}' -auto-orient -filter Lanczos -resize '${width}x${height}^' '${dstEsc}'`;
|
||||
|
||||
runProcess(command, cacheKey, outputPath, sourcePath);
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// ImageMagick Processing: Circular Avatar
|
||||
// -------------------------------------------------
|
||||
function downloadAndProcessAvatar(url, username, outputPath, cacheKey) {
|
||||
const tempPath = contributorsDir + username + "_temp.png";
|
||||
const tempEsc = tempPath.replace(/'/g, "'\\''");
|
||||
const urlEsc = url.replace(/'/g, "'\\''");
|
||||
|
||||
// Download first (uses utility queue since curl/wget are lightweight)
|
||||
const downloadCmd = `curl -L -s -o '${tempEsc}' '${urlEsc}' || wget -q -O '${tempEsc}' '${urlEsc}'`;
|
||||
|
||||
const processString = `
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
Process {
|
||||
command: ["sh", "-c", "${downloadCmd.replace(/"/g, '\\"')}"]
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {}
|
||||
}
|
||||
`;
|
||||
|
||||
queueUtilityProcess({
|
||||
name: "DownloadProcess_" + cacheKey,
|
||||
processString: processString,
|
||||
onComplete: function (exitCode) {
|
||||
if (exitCode !== 0) {
|
||||
Logger.e("ImageCache", "Failed to download avatar for", username);
|
||||
notifyCallbacks(cacheKey, "", false);
|
||||
return;
|
||||
}
|
||||
// Now process with ImageMagick
|
||||
processCircularAvatar(tempPath, outputPath, cacheKey);
|
||||
},
|
||||
onError: function () {
|
||||
notifyCallbacks(cacheKey, "", false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function processCircularAvatar(inputPath, outputPath, cacheKey) {
|
||||
const srcEsc = inputPath.replace(/'/g, "'\\''");
|
||||
const dstEsc = outputPath.replace(/'/g, "'\\''");
|
||||
|
||||
// ImageMagick command for circular crop with alpha
|
||||
const command = `magick '${srcEsc}' -resize 256x256^ -gravity center -extent 256x256 -alpha set \\( +clone -channel A -evaluate set 0 +channel -fill white -draw 'circle 128,128 128,0' \\) -compose DstIn -composite '${dstEsc}'`;
|
||||
|
||||
queueImageMagickProcess({
|
||||
command: command,
|
||||
cacheKey: cacheKey,
|
||||
onComplete: function (exitCode) {
|
||||
// Clean up temp file
|
||||
Quickshell.execDetached(["rm", "-f", inputPath]);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
Logger.e("ImageCache", "Failed to create circular avatar");
|
||||
notifyCallbacks(cacheKey, "", false);
|
||||
} else {
|
||||
Logger.d("ImageCache", "Circular avatar created:", outputPath);
|
||||
notifyCallbacks(cacheKey, outputPath, true);
|
||||
}
|
||||
},
|
||||
onError: function () {
|
||||
Quickshell.execDetached(["rm", "-f", inputPath]);
|
||||
notifyCallbacks(cacheKey, "", false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Generic Process Runner (with queue for ImageMagick)
|
||||
// -------------------------------------------------
|
||||
|
||||
// Queue an ImageMagick process and run it when a slot is available
|
||||
function queueImageMagickProcess(request) {
|
||||
imageMagickQueue.push(request);
|
||||
processImageMagickQueue();
|
||||
}
|
||||
|
||||
// Process queued ImageMagick requests up to the concurrency limit
|
||||
function processImageMagickQueue() {
|
||||
while (runningImageMagickProcesses < maxConcurrentImageMagickProcesses && imageMagickQueue.length > 0) {
|
||||
const request = imageMagickQueue.shift();
|
||||
runImageMagickProcess(request);
|
||||
}
|
||||
}
|
||||
|
||||
// Actually run an ImageMagick process
|
||||
function runImageMagickProcess(request) {
|
||||
runningImageMagickProcesses++;
|
||||
|
||||
const processString = `
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
Process {
|
||||
command: ["sh", "-c", ""]
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const processObj = Qt.createQmlObject(processString, root, "ImageProcess_" + request.cacheKey);
|
||||
processObj.command = ["sh", "-c", request.command];
|
||||
|
||||
processObj.exited.connect(function (exitCode) {
|
||||
processObj.destroy();
|
||||
runningImageMagickProcesses--;
|
||||
request.onComplete(exitCode, processObj);
|
||||
processImageMagickQueue();
|
||||
});
|
||||
|
||||
processObj.running = true;
|
||||
} catch (e) {
|
||||
Logger.e("ImageCache", "Failed to create process:", e);
|
||||
runningImageMagickProcesses--;
|
||||
request.onError(e);
|
||||
processImageMagickQueue();
|
||||
}
|
||||
}
|
||||
|
||||
function runProcess(command, cacheKey, outputPath, sourcePath) {
|
||||
queueImageMagickProcess({
|
||||
command: command,
|
||||
cacheKey: cacheKey,
|
||||
onComplete: function (exitCode, proc) {
|
||||
if (exitCode !== 0) {
|
||||
const stderrText = proc.stderr.text || "";
|
||||
Logger.e("ImageCache", "Processing failed:", stderrText);
|
||||
notifyCallbacks(cacheKey, sourcePath, false);
|
||||
} else {
|
||||
Logger.d("ImageCache", "Processing complete:", outputPath);
|
||||
notifyCallbacks(cacheKey, outputPath, true);
|
||||
}
|
||||
},
|
||||
onError: function () {
|
||||
notifyCallbacks(cacheKey, sourcePath, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Qt Fallback Renderer
|
||||
// -------------------------------------------------
|
||||
PanelWindow {
|
||||
id: fallbackRenderer
|
||||
implicitWidth: 0
|
||||
implicitHeight: 0
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
WlrLayershell.namespace: "noctalia-image-cache-renderer"
|
||||
color: "transparent"
|
||||
mask: Region {}
|
||||
|
||||
Image {
|
||||
id: fallbackImage
|
||||
property string cacheKey: ""
|
||||
property string destPath: ""
|
||||
property int targetSize: 256
|
||||
|
||||
width: targetSize
|
||||
height: targetSize
|
||||
visible: true
|
||||
cache: false
|
||||
asynchronous: true
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
mipmap: true
|
||||
antialiasing: true
|
||||
|
||||
onStatusChanged: {
|
||||
if (!cacheKey)
|
||||
return;
|
||||
|
||||
if (status === Image.Ready) {
|
||||
grabToImage(function (result) {
|
||||
if (result.saveToFile(destPath)) {
|
||||
Logger.d("ImageCache", "Fallback cache created:", destPath);
|
||||
root.notifyCallbacks(cacheKey, destPath, true);
|
||||
} else {
|
||||
Logger.e("ImageCache", "Failed to save fallback cache");
|
||||
root.notifyCallbacks(cacheKey, "", false);
|
||||
}
|
||||
processNextFallback();
|
||||
});
|
||||
} else if (status === Image.Error) {
|
||||
Logger.e("ImageCache", "Fallback image load failed");
|
||||
root.notifyCallbacks(cacheKey, "", false);
|
||||
processNextFallback();
|
||||
}
|
||||
}
|
||||
|
||||
function processNextFallback() {
|
||||
cacheKey = "";
|
||||
destPath = "";
|
||||
source = "";
|
||||
|
||||
if (fallbackQueue.length > 0) {
|
||||
const next = fallbackQueue.shift();
|
||||
cacheKey = next.cacheKey;
|
||||
destPath = next.destPath;
|
||||
targetSize = next.size;
|
||||
source = next.sourcePath;
|
||||
} else {
|
||||
fallbackProcessing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queueFallbackProcessing(sourcePath, destPath, cacheKey, size) {
|
||||
fallbackQueue.push({
|
||||
sourcePath: sourcePath,
|
||||
destPath: destPath,
|
||||
cacheKey: cacheKey,
|
||||
size: size
|
||||
});
|
||||
|
||||
if (!fallbackProcessing) {
|
||||
fallbackProcessing = true;
|
||||
const item = fallbackQueue.shift();
|
||||
fallbackImage.cacheKey = item.cacheKey;
|
||||
fallbackImage.destPath = item.destPath;
|
||||
fallbackImage.targetSize = item.size;
|
||||
fallbackImage.source = item.sourcePath;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Utility Functions (with process queue to prevent fd exhaustion)
|
||||
// -------------------------------------------------
|
||||
|
||||
// Queue a utility process and run it when a slot is available
|
||||
function queueUtilityProcess(request) {
|
||||
utilityProcessQueue.push(request);
|
||||
processUtilityQueue();
|
||||
}
|
||||
|
||||
// Process queued utility requests up to the concurrency limit
|
||||
function processUtilityQueue() {
|
||||
while (runningUtilityProcesses < maxConcurrentUtilityProcesses && utilityProcessQueue.length > 0) {
|
||||
const request = utilityProcessQueue.shift();
|
||||
runUtilityProcess(request);
|
||||
}
|
||||
}
|
||||
|
||||
// Actually run a utility process
|
||||
function runUtilityProcess(request) {
|
||||
runningUtilityProcesses++;
|
||||
|
||||
try {
|
||||
const processObj = Qt.createQmlObject(request.processString, root, request.name);
|
||||
|
||||
processObj.exited.connect(function (exitCode) {
|
||||
processObj.destroy();
|
||||
runningUtilityProcesses--;
|
||||
request.onComplete(exitCode, processObj);
|
||||
processUtilityQueue();
|
||||
});
|
||||
|
||||
processObj.running = true;
|
||||
} catch (e) {
|
||||
Logger.e("ImageCache", "Failed to create " + request.name + ":", e);
|
||||
runningUtilityProcesses--;
|
||||
request.onError(e);
|
||||
processUtilityQueue();
|
||||
}
|
||||
}
|
||||
|
||||
function getMtime(filePath, callback) {
|
||||
const pathEsc = filePath.replace(/'/g, "'\\''");
|
||||
const processString = `
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
Process {
|
||||
command: ["stat", "-c", "%Y", "${pathEsc}"]
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {}
|
||||
}
|
||||
`;
|
||||
|
||||
queueUtilityProcess({
|
||||
name: "MtimeProcess",
|
||||
processString: processString,
|
||||
onComplete: function (exitCode, proc) {
|
||||
const mtime = exitCode === 0 ? proc.stdout.text.trim() : "";
|
||||
callback(mtime);
|
||||
},
|
||||
onError: function () {
|
||||
callback("");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function checkFileExists(filePath, callback) {
|
||||
const pathEsc = filePath.replace(/'/g, "'\\''");
|
||||
const processString = `
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
Process {
|
||||
command: ["test", "-f", "${pathEsc}"]
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {}
|
||||
}
|
||||
`;
|
||||
|
||||
queueUtilityProcess({
|
||||
name: "FileExistsProcess",
|
||||
processString: processString,
|
||||
onComplete: function (exitCode) {
|
||||
callback(exitCode === 0);
|
||||
},
|
||||
onError: function () {
|
||||
callback(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getImageDimensions(filePath, callback) {
|
||||
const pathEsc = filePath.replace(/'/g, "'\\''");
|
||||
const processString = `
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
Process {
|
||||
command: ["identify", "-ping", "-format", "%w %h", "${pathEsc}[0]"]
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {}
|
||||
}
|
||||
`;
|
||||
|
||||
queueUtilityProcess({
|
||||
name: "IdentifyProcess",
|
||||
processString: processString,
|
||||
onComplete: function (exitCode, proc) {
|
||||
let width = 0, height = 0;
|
||||
if (exitCode === 0) {
|
||||
const parts = proc.stdout.text.trim().split(" ");
|
||||
if (parts.length >= 2) {
|
||||
width = parseInt(parts[0], 10) || 0;
|
||||
height = parseInt(parts[1], 10) || 0;
|
||||
}
|
||||
}
|
||||
callback(width, height);
|
||||
},
|
||||
onError: function () {
|
||||
callback(0, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Cache Invalidation
|
||||
// -------------------------------------------------
|
||||
function invalidateThumbnail(sourcePath) {
|
||||
Logger.i("ImageCache", "Invalidating thumbnail for:", sourcePath);
|
||||
// Since cache keys include hash, we'd need to track mappings
|
||||
// For simplicity, clear all thumbnails
|
||||
clearThumbnails();
|
||||
}
|
||||
|
||||
function invalidateLarge(sourcePath) {
|
||||
Logger.i("ImageCache", "Invalidating large for:", sourcePath);
|
||||
clearLarge();
|
||||
}
|
||||
|
||||
function invalidateNotification(imageId) {
|
||||
const path = notificationsDir + imageId + ".png";
|
||||
Quickshell.execDetached(["rm", "-f", path]);
|
||||
}
|
||||
|
||||
function invalidateAvatar(username) {
|
||||
const path = contributorsDir + username + "_circular.png";
|
||||
Quickshell.execDetached(["rm", "-f", path]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Clear Cache Functions
|
||||
// -------------------------------------------------
|
||||
function clearAll() {
|
||||
Logger.i("ImageCache", "Clearing all cache");
|
||||
clearThumbnails();
|
||||
clearLarge();
|
||||
clearNotifications();
|
||||
clearContributors();
|
||||
}
|
||||
|
||||
function clearThumbnails() {
|
||||
Logger.i("ImageCache", "Clearing thumbnails cache");
|
||||
Quickshell.execDetached(["rm", "-rf", wpThumbDir]);
|
||||
Quickshell.execDetached(["mkdir", "-p", wpThumbDir]);
|
||||
}
|
||||
|
||||
function clearLarge() {
|
||||
Logger.i("ImageCache", "Clearing large cache");
|
||||
Quickshell.execDetached(["rm", "-rf", wpLargeDir]);
|
||||
Quickshell.execDetached(["mkdir", "-p", wpLargeDir]);
|
||||
}
|
||||
|
||||
function clearNotifications() {
|
||||
Logger.i("ImageCache", "Clearing notifications cache");
|
||||
Quickshell.execDetached(["rm", "-rf", notificationsDir]);
|
||||
Quickshell.execDetached(["mkdir", "-p", notificationsDir]);
|
||||
}
|
||||
|
||||
function clearContributors() {
|
||||
Logger.i("ImageCache", "Clearing contributors cache");
|
||||
Quickshell.execDetached(["rm", "-rf", contributorsDir]);
|
||||
Quickshell.execDetached(["mkdir", "-p", contributorsDir]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// ImageMagick Detection
|
||||
// -------------------------------------------------
|
||||
Process {
|
||||
id: checkMagickProcess
|
||||
command: ["sh", "-c", "command -v magick"]
|
||||
running: false
|
||||
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {}
|
||||
|
||||
onExited: function (exitCode) {
|
||||
root.imageMagickAvailable = (exitCode === 0);
|
||||
root.initialized = true;
|
||||
if (root.imageMagickAvailable) {
|
||||
Logger.i("ImageCache", "ImageMagick available");
|
||||
} else {
|
||||
Logger.w("ImageCache", "ImageMagick not found, using Qt fallback");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Noctalia
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
signal pluginProviderRegistryUpdated
|
||||
|
||||
// Plugin provider storage
|
||||
property var pluginProviders: ({}) // { "plugin:pluginId": component }
|
||||
property var pluginProviderMetadata: ({}) // { "plugin:pluginId": metadata }
|
||||
|
||||
// Persistent provider instances — survive LauncherCore destruction/recreation
|
||||
// so plugins don't re-parse large datasets on every launcher open.
|
||||
property var providerInstances: ({}) // { "plugin:pluginId": instance }
|
||||
|
||||
function init() {
|
||||
Logger.i("LauncherProviderRegistry", "Service started");
|
||||
}
|
||||
|
||||
// Register a plugin launcher provider and instantiate it immediately
|
||||
function registerPluginProvider(pluginId, component, metadata) {
|
||||
if (!pluginId || !component) {
|
||||
Logger.e("LauncherProviderRegistry", "Cannot register plugin provider: invalid parameters");
|
||||
return false;
|
||||
}
|
||||
|
||||
var providerId = "plugin:" + pluginId;
|
||||
|
||||
pluginProviders[providerId] = component;
|
||||
pluginProviderMetadata[providerId] = metadata || {};
|
||||
|
||||
// Instantiate immediately so data loading starts in the background
|
||||
var pluginApi = PluginService.getPluginAPI(pluginId);
|
||||
if (pluginApi) {
|
||||
var instance = component.createObject(null, {
|
||||
pluginApi: pluginApi
|
||||
});
|
||||
if (instance) {
|
||||
providerInstances[providerId] = instance;
|
||||
if (instance.init)
|
||||
instance.init();
|
||||
Logger.i("LauncherProviderRegistry", "Registered and instantiated plugin provider:", providerId);
|
||||
} else {
|
||||
Logger.e("LauncherProviderRegistry", "Failed to instantiate plugin provider:", providerId);
|
||||
}
|
||||
}
|
||||
|
||||
root.pluginProviderRegistryUpdated();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Unregister a plugin launcher provider
|
||||
function unregisterPluginProvider(pluginId) {
|
||||
var providerId = "plugin:" + pluginId;
|
||||
|
||||
if (!pluginProviders[providerId]) {
|
||||
Logger.w("LauncherProviderRegistry", "Plugin provider not registered:", providerId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (providerInstances[providerId]) {
|
||||
providerInstances[providerId].destroy();
|
||||
delete providerInstances[providerId];
|
||||
}
|
||||
|
||||
delete pluginProviders[providerId];
|
||||
delete pluginProviderMetadata[providerId];
|
||||
|
||||
Logger.i("LauncherProviderRegistry", "Unregistered plugin provider:", providerId);
|
||||
root.pluginProviderRegistryUpdated();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get list of registered plugin provider IDs
|
||||
function getPluginProviders() {
|
||||
return Object.keys(pluginProviders);
|
||||
}
|
||||
|
||||
// Get the live provider instance by ID
|
||||
function getProviderInstance(providerId) {
|
||||
return providerInstances[providerId] || null;
|
||||
}
|
||||
|
||||
// Get provider component by ID
|
||||
function getProviderComponent(providerId) {
|
||||
return pluginProviders[providerId] || null;
|
||||
}
|
||||
|
||||
// Get provider metadata by ID
|
||||
function getProviderMetadata(providerId) {
|
||||
return pluginProviderMetadata[providerId] || null;
|
||||
}
|
||||
|
||||
// Check if ID is a plugin provider
|
||||
function isPluginProvider(id) {
|
||||
return id.startsWith("plugin:");
|
||||
}
|
||||
|
||||
// Check if provider exists
|
||||
function hasProvider(providerId) {
|
||||
return providerId in pluginProviders;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Compositor
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// A ref. to the lockScreen, so it's accessible from anywhere.
|
||||
property var lockScreen: null
|
||||
|
||||
// Panels
|
||||
property var registeredPanels: ({})
|
||||
property var openedPanel: null
|
||||
property var closingPanel: null
|
||||
property bool closedImmediately: false
|
||||
|
||||
// Overlay launcher state (separate from normal panels)
|
||||
property bool overlayLauncherOpen: false
|
||||
property var overlayLauncherScreen: null
|
||||
property var overlayLauncherCore: null // Reference to LauncherCore when overlay is active
|
||||
// Brief window after panel opens where Exclusive keyboard is allowed on Hyprland
|
||||
// This allows text inputs to receive focus, then switches to OnDemand for click-to-close
|
||||
property bool isInitializingKeyboard: false
|
||||
|
||||
// Global state for keybind recording components to block global shortcuts
|
||||
property bool isKeybindRecording: false
|
||||
|
||||
signal willOpen
|
||||
signal didClose
|
||||
|
||||
// Background slot assignments for dynamic panel background rendering
|
||||
// Slot 0: currently opening/open panel, Slot 1: closing panel
|
||||
property var backgroundSlotAssignments: [null, null]
|
||||
signal slotAssignmentChanged(int slotIndex, var panel)
|
||||
|
||||
function assignToSlot(slotIndex, panel) {
|
||||
if (backgroundSlotAssignments[slotIndex] !== panel) {
|
||||
var newAssignments = backgroundSlotAssignments.slice();
|
||||
newAssignments[slotIndex] = panel;
|
||||
backgroundSlotAssignments = newAssignments;
|
||||
slotAssignmentChanged(slotIndex, panel);
|
||||
}
|
||||
}
|
||||
|
||||
// Popup menu windows (one per screen) - used for both tray menus and context menus
|
||||
property var popupMenuWindows: ({})
|
||||
signal popupMenuWindowRegistered(var screen)
|
||||
|
||||
// Register this panel (called after panel is loaded)
|
||||
function registerPanel(panel) {
|
||||
registeredPanels[panel.objectName] = panel;
|
||||
Logger.d("PanelService", "Registered panel:", panel.objectName);
|
||||
}
|
||||
|
||||
// Register popup menu window for a screen
|
||||
function registerPopupMenuWindow(screen, window) {
|
||||
if (!screen || !window)
|
||||
return;
|
||||
var key = screen.name;
|
||||
popupMenuWindows[key] = window;
|
||||
Logger.d("PanelService", "Registered popup menu window for screen:", key);
|
||||
popupMenuWindowRegistered(screen);
|
||||
}
|
||||
|
||||
// Unregister popup menu window for a screen (called on destruction)
|
||||
function unregisterPopupMenuWindow(screen) {
|
||||
if (!screen)
|
||||
return;
|
||||
var key = screen.name;
|
||||
delete popupMenuWindows[key];
|
||||
Logger.d("PanelService", "Unregistered popup menu window for screen:", key);
|
||||
}
|
||||
|
||||
// Get popup menu window for a screen
|
||||
function getPopupMenuWindow(screen) {
|
||||
if (!screen)
|
||||
return null;
|
||||
return popupMenuWindows[screen.name] || null;
|
||||
}
|
||||
|
||||
// Show a context menu with proper handling for all compositors
|
||||
// Optional targetItem: if provided, menu will be horizontally centered on this item instead of anchorItem
|
||||
function showContextMenu(contextMenu, anchorItem, screen, targetItem) {
|
||||
if (!contextMenu || !anchorItem)
|
||||
return;
|
||||
|
||||
// Close any previously opened context menu first
|
||||
closeContextMenu(screen);
|
||||
|
||||
var popupMenuWindow = getPopupMenuWindow(screen);
|
||||
if (popupMenuWindow) {
|
||||
popupMenuWindow.showContextMenu(contextMenu);
|
||||
contextMenu.openAtItem(anchorItem, screen, targetItem);
|
||||
}
|
||||
}
|
||||
|
||||
// Close any open context menu or popup menu window
|
||||
function closeContextMenu(screen) {
|
||||
var popupMenuWindow = getPopupMenuWindow(screen);
|
||||
if (popupMenuWindow && popupMenuWindow.visible) {
|
||||
popupMenuWindow.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Show a tray menu with proper handling for all compositors
|
||||
// Returns true if menu was shown successfully
|
||||
function showTrayMenu(screen, trayItem, trayMenu, anchorItem, menuX, menuY, widgetSection, widgetIndex) {
|
||||
if (!trayItem || !trayMenu || !anchorItem)
|
||||
return false;
|
||||
|
||||
// Close any previously opened menu first
|
||||
closeContextMenu(screen);
|
||||
|
||||
trayMenu.trayItem = trayItem;
|
||||
trayMenu.widgetSection = widgetSection;
|
||||
trayMenu.widgetIndex = widgetIndex;
|
||||
|
||||
var popupMenuWindow = getPopupMenuWindow(screen);
|
||||
if (popupMenuWindow) {
|
||||
popupMenuWindow.open();
|
||||
trayMenu.showAt(anchorItem, menuX, menuY);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Close tray menu
|
||||
function closeTrayMenu(screen) {
|
||||
var popupMenuWindow = getPopupMenuWindow(screen);
|
||||
if (popupMenuWindow) {
|
||||
// This closes both the window and calls hideMenu on the tray menu
|
||||
popupMenuWindow.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Find a fallback screen, prioritizing 0x0 position (primary)
|
||||
function findFallbackScreen() {
|
||||
let primaryCandidate = null;
|
||||
let firstScreen = null;
|
||||
|
||||
for (let i = 0; i < Quickshell.screens.length; i++) {
|
||||
const s = Quickshell.screens[i];
|
||||
if (s.x === 0 && s.y === 0) {
|
||||
primaryCandidate = s;
|
||||
}
|
||||
if (!firstScreen) {
|
||||
firstScreen = s;
|
||||
}
|
||||
}
|
||||
|
||||
return primaryCandidate || firstScreen || null;
|
||||
}
|
||||
|
||||
// Returns a panel (loads it on-demand if not yet loaded)
|
||||
// By default, if panel not found on screen, tries other screens (favoring 0x0)
|
||||
// Pass fallback=false to disable this behavior
|
||||
function getPanel(name, screen, fallback = true) {
|
||||
if (!screen) {
|
||||
Logger.d("PanelService", "missing screen for getPanel:", name);
|
||||
// If no screen specified, return the first matching panel
|
||||
for (var key in registeredPanels) {
|
||||
if (key.startsWith(name + "-")) {
|
||||
return registeredPanels[key];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var panelKey = `${name}-${screen.name}`;
|
||||
|
||||
// Check if panel is already loaded
|
||||
if (registeredPanels[panelKey]) {
|
||||
return registeredPanels[panelKey];
|
||||
}
|
||||
|
||||
// If fallback enabled, try to find panel on another screen
|
||||
if (fallback) {
|
||||
// First try the primary screen (0x0)
|
||||
var fallbackScreen = findFallbackScreen();
|
||||
if (fallbackScreen && fallbackScreen.name !== screen.name) {
|
||||
var fallbackKey = `${name}-${fallbackScreen.name}`;
|
||||
if (registeredPanels[fallbackKey]) {
|
||||
Logger.d("PanelService", "Panel fallback from", screen.name, "to", fallbackScreen.name);
|
||||
return registeredPanels[fallbackKey];
|
||||
}
|
||||
}
|
||||
|
||||
// Try any other screen
|
||||
for (var key in registeredPanels) {
|
||||
if (key.startsWith(name + "-")) {
|
||||
Logger.d("PanelService", "Panel fallback to first available:", key);
|
||||
return registeredPanels[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.w("PanelService", "Panel not found:", panelKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if a panel exists
|
||||
function hasPanel(name) {
|
||||
return name in registeredPanels;
|
||||
}
|
||||
|
||||
// Check if panels can be shown on a given screen (has bar enabled or allowPanelsOnScreenWithoutBar)
|
||||
function canShowPanelsOnScreen(screen) {
|
||||
const name = screen?.name || "";
|
||||
const monitors = Settings.data.bar.monitors || [];
|
||||
const allowPanelsOnScreenWithoutBar = Settings.data.general.allowPanelsOnScreenWithoutBar;
|
||||
return allowPanelsOnScreenWithoutBar || monitors.length === 0 || monitors.includes(name);
|
||||
}
|
||||
|
||||
// Find a screen that can show panels
|
||||
function findScreenForPanels() {
|
||||
for (let i = 0; i < Quickshell.screens.length; i++) {
|
||||
if (canShowPanelsOnScreen(Quickshell.screens[i])) {
|
||||
return Quickshell.screens[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Timer to switch from Exclusive to OnDemand keyboard focus on Hyprland
|
||||
Timer {
|
||||
id: keyboardInitTimer
|
||||
interval: 100
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
root.isInitializingKeyboard = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to keep only one panel open at any time
|
||||
function willOpenPanel(panel) {
|
||||
// Close overlay launcher if open
|
||||
if (overlayLauncherOpen) {
|
||||
overlayLauncherOpen = false;
|
||||
overlayLauncherScreen = null;
|
||||
}
|
||||
|
||||
if (openedPanel && openedPanel !== panel) {
|
||||
// Move current panel to closing slot before closing it
|
||||
closingPanel = openedPanel;
|
||||
assignToSlot(1, closingPanel);
|
||||
openedPanel.close();
|
||||
}
|
||||
|
||||
// Assign new panel to open slot
|
||||
openedPanel = panel;
|
||||
assignToSlot(0, panel);
|
||||
|
||||
// Start keyboard initialization period (for Hyprland workaround)
|
||||
if (panel && panel.exclusiveKeyboard) {
|
||||
isInitializingKeyboard = true;
|
||||
keyboardInitTimer.restart();
|
||||
}
|
||||
|
||||
// emit signal
|
||||
willOpen();
|
||||
}
|
||||
|
||||
// Open launcher panel (handles both normal and overlay mode)
|
||||
function openLauncher(screen) {
|
||||
if (Settings.data.appLauncher.overviewLayer) {
|
||||
// Close any regular panel first
|
||||
if (openedPanel) {
|
||||
closingPanel = openedPanel;
|
||||
assignToSlot(1, closingPanel);
|
||||
openedPanel.close();
|
||||
openedPanel = null;
|
||||
}
|
||||
// Open overlay launcher
|
||||
overlayLauncherOpen = true;
|
||||
overlayLauncherScreen = screen;
|
||||
willOpen();
|
||||
} else {
|
||||
// Normal mode - use the SmartPanel
|
||||
var panel = getPanel("launcherPanel", screen);
|
||||
if (panel)
|
||||
panel.open();
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle launcher panel
|
||||
function toggleLauncher(screen) {
|
||||
if (Settings.data.appLauncher.overviewLayer) {
|
||||
if (overlayLauncherOpen && overlayLauncherScreen === screen) {
|
||||
closeOverlayLauncher();
|
||||
} else {
|
||||
openLauncher(screen);
|
||||
}
|
||||
} else {
|
||||
var panel = getPanel("launcherPanel", screen);
|
||||
if (panel)
|
||||
panel.toggle();
|
||||
}
|
||||
}
|
||||
|
||||
// Close overlay launcher
|
||||
function closeOverlayLauncher() {
|
||||
if (overlayLauncherOpen) {
|
||||
overlayLauncherOpen = false;
|
||||
overlayLauncherScreen = null;
|
||||
didClose();
|
||||
}
|
||||
}
|
||||
|
||||
// Close overlay launcher immediately (for app launches)
|
||||
function closeOverlayLauncherImmediately() {
|
||||
if (overlayLauncherOpen) {
|
||||
closedImmediately = true;
|
||||
overlayLauncherOpen = false;
|
||||
overlayLauncherScreen = null;
|
||||
didClose();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Unified Launcher API ====================
|
||||
// These methods work for both normal (SmartPanel) and overlay modes
|
||||
|
||||
function isLauncherOpen(screen) {
|
||||
if (Settings.data.appLauncher.overviewLayer) {
|
||||
return overlayLauncherOpen && overlayLauncherScreen === screen;
|
||||
} else {
|
||||
var panel = getPanel("launcherPanel", screen);
|
||||
return panel ? panel.isPanelOpen : false;
|
||||
}
|
||||
}
|
||||
|
||||
function getLauncherSearchText(screen) {
|
||||
if (Settings.data.appLauncher.overviewLayer) {
|
||||
return overlayLauncherCore ? overlayLauncherCore.searchText : "";
|
||||
} else {
|
||||
var panel = getPanel("launcherPanel", screen);
|
||||
return panel ? panel.searchText : "";
|
||||
}
|
||||
}
|
||||
|
||||
function setLauncherSearchText(screen, text) {
|
||||
if (Settings.data.appLauncher.overviewLayer) {
|
||||
if (overlayLauncherCore)
|
||||
overlayLauncherCore.setSearchText(text);
|
||||
} else {
|
||||
var panel = getPanel("launcherPanel", screen);
|
||||
if (panel)
|
||||
panel.setSearchText(text);
|
||||
}
|
||||
}
|
||||
|
||||
function openLauncherWithSearch(screen, searchText) {
|
||||
if (Settings.data.appLauncher.overviewLayer) {
|
||||
openLauncher(screen);
|
||||
// Set search text after core is ready
|
||||
Qt.callLater(() => {
|
||||
if (overlayLauncherCore)
|
||||
overlayLauncherCore.setSearchText(searchText);
|
||||
});
|
||||
} else {
|
||||
var panel = getPanel("launcherPanel", screen);
|
||||
if (panel) {
|
||||
panel.open();
|
||||
panel.setSearchText(searchText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeLauncher(screen) {
|
||||
if (Settings.data.appLauncher.overviewLayer) {
|
||||
closeOverlayLauncher();
|
||||
} else {
|
||||
var panel = getPanel("launcherPanel", screen);
|
||||
if (panel)
|
||||
panel.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Close any open panel (for general use)
|
||||
function closePanel() {
|
||||
if (overlayLauncherOpen) {
|
||||
closeOverlayLauncher();
|
||||
} else if (openedPanel && openedPanel.close) {
|
||||
openedPanel.close();
|
||||
}
|
||||
}
|
||||
|
||||
function closedPanel(panel) {
|
||||
if (openedPanel && openedPanel === panel) {
|
||||
openedPanel = null;
|
||||
assignToSlot(0, null);
|
||||
}
|
||||
|
||||
if (closingPanel && closingPanel === panel) {
|
||||
closingPanel = null;
|
||||
assignToSlot(1, null);
|
||||
}
|
||||
|
||||
// Reset keyboard init state
|
||||
isInitializingKeyboard = false;
|
||||
keyboardInitTimer.stop();
|
||||
|
||||
// emit signal
|
||||
didClose();
|
||||
}
|
||||
|
||||
// Close panels when compositor overview opens (if setting is enabled)
|
||||
Connections {
|
||||
target: CompositorService
|
||||
enabled: Settings.data.bar.hideOnOverview
|
||||
|
||||
function onOverviewActiveChanged() {
|
||||
if (CompositorService.overviewActive && root.openedPanel) {
|
||||
root.openedPanel.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Track if the settings window is open
|
||||
property bool isWindowOpen: false
|
||||
|
||||
// Reference to the window (set by SettingsPanelWindow)
|
||||
property var settingsWindow: null
|
||||
|
||||
// Requested tab when opening
|
||||
property int requestedTab: 0
|
||||
|
||||
// Requested subtab when opening (-1 means no specific subtab)
|
||||
property int requestedSubTab: -1
|
||||
|
||||
// Requested entry for search navigation
|
||||
property var requestedEntry: null
|
||||
|
||||
signal windowOpened
|
||||
signal windowClosed
|
||||
|
||||
function openToEntry(entry, screen) {
|
||||
if (Settings.data.ui.settingsPanelMode === "window") {
|
||||
requestedEntry = entry;
|
||||
if (settingsWindow) {
|
||||
settingsWindow.visible = true;
|
||||
isWindowOpen = true;
|
||||
windowOpened();
|
||||
settingsWindow.navigateToEntry(entry);
|
||||
}
|
||||
} else {
|
||||
if (!screen) {
|
||||
Logger.w("SettingsPanelService", "Screen parameter required for panel mode");
|
||||
return;
|
||||
}
|
||||
var settingsPanel = PanelService.getPanel("settingsPanel", screen);
|
||||
if (settingsPanel) {
|
||||
settingsPanel.requestedEntry = entry;
|
||||
settingsPanel.open();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unified function to open settings to a specific tab and subtab
|
||||
// Respects user's settingsPanelMode setting (window vs panel)
|
||||
// For panel mode, screen parameter is required
|
||||
function openToTab(tab, subTab, screen) {
|
||||
const tabId = tab !== undefined ? tab : 0;
|
||||
const subTabId = subTab !== undefined ? subTab : -1;
|
||||
|
||||
if (Settings.data.ui.settingsPanelMode === "window") {
|
||||
requestedTab = tabId;
|
||||
requestedSubTab = subTabId;
|
||||
if (settingsWindow) {
|
||||
settingsWindow.visible = true;
|
||||
isWindowOpen = true;
|
||||
windowOpened();
|
||||
settingsWindow.navigateTo(tabId, subTabId);
|
||||
}
|
||||
} else {
|
||||
if (!screen) {
|
||||
Logger.w("SettingsPanelService", "Screen parameter required for panel mode");
|
||||
return;
|
||||
}
|
||||
var settingsPanel = PanelService.getPanel("settingsPanel", screen);
|
||||
if (settingsPanel) {
|
||||
settingsPanel.openToTab(tabId, subTabId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openWindow(tab) {
|
||||
requestedTab = tab !== undefined ? tab : 0;
|
||||
requestedSubTab = -1;
|
||||
if (settingsWindow) {
|
||||
settingsWindow.visible = true;
|
||||
isWindowOpen = true;
|
||||
windowOpened();
|
||||
settingsWindow.navigateTo(requestedTab, -1);
|
||||
}
|
||||
}
|
||||
|
||||
function closeWindow() {
|
||||
if (settingsWindow) {
|
||||
settingsWindow.visible = false;
|
||||
isWindowOpen = false;
|
||||
windowClosed();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleWindow(tab) {
|
||||
if (isWindowOpen) {
|
||||
closeWindow();
|
||||
} else {
|
||||
openWindow(tab);
|
||||
}
|
||||
}
|
||||
|
||||
// Unified toggle: opens to tab/subtab if closed, closes if open
|
||||
// Respects settingsPanelMode setting
|
||||
function toggle(tab, subTab, screen) {
|
||||
const tabId = tab !== undefined ? tab : 0;
|
||||
const subTabId = subTab !== undefined ? subTab : -1;
|
||||
|
||||
if (Settings.data.ui.settingsPanelMode === "window") {
|
||||
if (isWindowOpen) {
|
||||
closeWindow();
|
||||
} else {
|
||||
openToTab(tabId, subTabId);
|
||||
}
|
||||
} else {
|
||||
if (!screen) {
|
||||
Logger.w("SettingsPanelService", "Screen parameter required for panel mode");
|
||||
return;
|
||||
}
|
||||
var settingsPanel = PanelService.getPanel("settingsPanel", screen);
|
||||
if (settingsPanel?.isPanelOpen) {
|
||||
settingsPanel.close();
|
||||
} else {
|
||||
settingsPanel?.openToTab(tabId, subTabId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unified close for both modes
|
||||
function close(screen) {
|
||||
if (Settings.data.ui.settingsPanelMode === "window") {
|
||||
closeWindow();
|
||||
} else {
|
||||
if (!screen)
|
||||
return;
|
||||
var settingsPanel = PanelService.getPanel("settingsPanel", screen);
|
||||
settingsPanel?.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Services.Compositor
|
||||
import qs.Services.Power
|
||||
import qs.Services.System
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var searchIndex: []
|
||||
|
||||
FileView {
|
||||
path: Quickshell.shellDir + "/Assets/settings-search-index.json"
|
||||
watchChanges: false
|
||||
printErrors: false
|
||||
|
||||
onLoaded: {
|
||||
try {
|
||||
root.searchIndex = JSON.parse(text());
|
||||
} catch (e) {
|
||||
root.searchIndex = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly property var _roots: ({
|
||||
"CompositorService": CompositorService,
|
||||
"Settings": Settings,
|
||||
"Quickshell": Quickshell,
|
||||
"IdleService": IdleService,
|
||||
"SystemStatService": SystemStatService,
|
||||
"SoundService": SoundService
|
||||
})
|
||||
|
||||
function isEntryVisible(entry) {
|
||||
if (!entry.visibleWhen || entry.visibleWhen.length === 0)
|
||||
return true;
|
||||
for (let i = 0; i < entry.visibleWhen.length; i++) {
|
||||
if (!_evalCondition(entry.visibleWhen[i]))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function _resolveValue(path) {
|
||||
const parts = path.split(".");
|
||||
const rootObj = _roots[parts[0]];
|
||||
if (rootObj === undefined)
|
||||
return undefined;
|
||||
|
||||
let obj = rootObj;
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
if (obj === undefined || obj === null)
|
||||
return undefined;
|
||||
let key = parts[i];
|
||||
if (key.endsWith("?"))
|
||||
key = key.slice(0, -1);
|
||||
obj = obj[key];
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function _splitAnd(expr) {
|
||||
const parts = [];
|
||||
let depth = 0;
|
||||
let current = "";
|
||||
for (let i = 0; i < expr.length; i++) {
|
||||
const ch = expr[i];
|
||||
if (ch === "(")
|
||||
depth++;
|
||||
else if (ch === ")")
|
||||
depth--;
|
||||
else if (depth === 0 && ch === "&" && i + 1 < expr.length && expr[i + 1] === "&") {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
parts.push(current);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function _evalCondition(expr) {
|
||||
expr = expr.trim();
|
||||
|
||||
// Strip outer parentheses
|
||||
if (expr.startsWith("(") && expr.endsWith(")")) {
|
||||
let depth = 0;
|
||||
let allWrapped = true;
|
||||
for (let i = 0; i < expr.length - 1; i++) {
|
||||
if (expr[i] === "(")
|
||||
depth++;
|
||||
else if (expr[i] === ")")
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
allWrapped = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allWrapped)
|
||||
return _evalCondition(expr.slice(1, -1));
|
||||
}
|
||||
|
||||
// AND: all parts must be true
|
||||
if (expr.includes("&&")) {
|
||||
const parts = _splitAnd(expr);
|
||||
if (parts.length > 1) {
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (!_evalCondition(parts[i]))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Negation
|
||||
if (expr.startsWith("!"))
|
||||
return !_evalCondition(expr.slice(1).trim());
|
||||
|
||||
// Literal false
|
||||
if (expr === "false")
|
||||
return false;
|
||||
|
||||
// Strip nullish coalescing fallback
|
||||
const nullishMatch = expr.match(/^(.+?)\s*\?\?\s*(?:false|true)\s*$/);
|
||||
if (nullishMatch)
|
||||
return _evalCondition(nullishMatch[1]);
|
||||
|
||||
// === comparison
|
||||
let m = expr.match(/^(.+?)\s*===\s*"([^"]*)"\s*$/);
|
||||
if (m)
|
||||
return _resolveValue(m[1].trim()) === m[2];
|
||||
|
||||
// !== comparison
|
||||
m = expr.match(/^(.+?)\s*!==\s*"([^"]*)"\s*$/);
|
||||
if (m)
|
||||
return _resolveValue(m[1].trim()) !== m[2];
|
||||
|
||||
// > comparison
|
||||
m = expr.match(/^(.+?)\s*>\s*(\d+)\s*$/);
|
||||
if (m)
|
||||
return _resolveValue(m[1].trim()) > parseInt(m[2]);
|
||||
|
||||
// Simple property path — resolve and return truthiness
|
||||
const val = _resolveValue(expr);
|
||||
if (val !== undefined)
|
||||
return !!val;
|
||||
|
||||
// Unrecognized expression — assume visible
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Simple signal-based notification system
|
||||
// actionLabel: optional label for clickable action link
|
||||
// actionCallback: optional function to call when action is clicked
|
||||
signal notify(string title, string description, string icon, string type, int duration, string actionLabel, var actionCallback)
|
||||
signal dismiss
|
||||
|
||||
// Convenience methods
|
||||
function showNotice(title, description = "", icon = "", duration = 3000, actionLabel = "", actionCallback = null) {
|
||||
notify(title, description, icon, "notice", duration, actionLabel, actionCallback);
|
||||
}
|
||||
|
||||
function showWarning(title, description = "", duration = 4000, actionLabel = "", actionCallback = null) {
|
||||
notify(title, description, "", "warning", duration, actionLabel, actionCallback);
|
||||
}
|
||||
|
||||
function showError(title, description = "", duration = 6000, actionLabel = "", actionCallback = null) {
|
||||
notify(title, description, "", "error", duration, actionLabel, actionCallback);
|
||||
}
|
||||
|
||||
function dismissToast() {
|
||||
dismiss();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.Tooltip
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var activeTooltip: null
|
||||
property var pendingTooltip: null // Track tooltip being created
|
||||
|
||||
property Component tooltipComponent: Component {
|
||||
Tooltip {}
|
||||
}
|
||||
|
||||
function show(target, content, direction, delay, fontFamily) {
|
||||
if (!Settings.data.ui.tooltipsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't create if no content
|
||||
if (!target || !content || (Array.isArray(content) && content.length === 0)) {
|
||||
Logger.i("Tooltip", "No target or content");
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any pending tooltip (same or different target)
|
||||
if (pendingTooltip) {
|
||||
pendingTooltip.hideImmediately();
|
||||
pendingTooltip.destroy();
|
||||
pendingTooltip = null;
|
||||
}
|
||||
|
||||
// If we have an active tooltip for a different target, hide it
|
||||
if (activeTooltip && activeTooltip.targetItem !== target) {
|
||||
activeTooltip.hideImmediately();
|
||||
// Don't destroy immediately - let it clean itself up
|
||||
activeTooltip = null;
|
||||
}
|
||||
|
||||
// If we already have a tooltip for this target, just update it
|
||||
if (activeTooltip && activeTooltip.targetItem === target) {
|
||||
activeTooltip.updateContent(content);
|
||||
return activeTooltip;
|
||||
}
|
||||
|
||||
// Create new tooltip instance
|
||||
const newTooltip = tooltipComponent.createObject(null);
|
||||
|
||||
if (newTooltip) {
|
||||
// Track as pending until it's visible
|
||||
pendingTooltip = newTooltip;
|
||||
|
||||
// Connect cleanup when tooltip hides
|
||||
newTooltip.visibleChanged.connect(() => {
|
||||
if (!newTooltip.visible) {
|
||||
// Clean up after a delay to avoid interfering with new tooltips
|
||||
Qt.callLater(() => {
|
||||
if (newTooltip && !newTooltip.visible) {
|
||||
if (activeTooltip === newTooltip) {
|
||||
activeTooltip = null;
|
||||
}
|
||||
if (pendingTooltip === newTooltip) {
|
||||
pendingTooltip = null;
|
||||
}
|
||||
newTooltip.destroy();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Tooltip is now visible, move from pending to active
|
||||
if (pendingTooltip === newTooltip) {
|
||||
activeTooltip = newTooltip;
|
||||
pendingTooltip = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Show the tooltip
|
||||
newTooltip.show(target, content, direction || "auto", delay || Style.tooltipDelay, fontFamily);
|
||||
|
||||
return newTooltip;
|
||||
} else {
|
||||
Logger.e("Tooltip", "Failed to create tooltip instance");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hide(target) {
|
||||
// If target is provided, only hide if tooltip belongs to that target
|
||||
if (target) {
|
||||
if (pendingTooltip && pendingTooltip.targetItem === target) {
|
||||
pendingTooltip.hide();
|
||||
}
|
||||
if (activeTooltip && activeTooltip.targetItem === target) {
|
||||
activeTooltip.hide();
|
||||
}
|
||||
} else {
|
||||
if (pendingTooltip) {
|
||||
pendingTooltip.hide();
|
||||
}
|
||||
if (activeTooltip) {
|
||||
activeTooltip.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hideImmediately() {
|
||||
if (pendingTooltip) {
|
||||
pendingTooltip.hideImmediately();
|
||||
pendingTooltip.destroy();
|
||||
pendingTooltip = null;
|
||||
}
|
||||
if (activeTooltip) {
|
||||
activeTooltip.hideImmediately();
|
||||
activeTooltip.destroy();
|
||||
activeTooltip = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateContent(newContent) {
|
||||
if (activeTooltip) {
|
||||
activeTooltip.updateContent(newContent);
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility alias
|
||||
function updateText(newText) {
|
||||
updateContent(newText);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// State
|
||||
property bool fetching: false
|
||||
property bool initialSearchScheduled: false
|
||||
property var currentResults: []
|
||||
property var currentMeta: ({})
|
||||
property string lastError: ""
|
||||
property string currentQuery: ""
|
||||
property int currentPage: 1
|
||||
property int lastPage: 1
|
||||
|
||||
// Search parameters
|
||||
property string categories: "111" // general,anime,people (all enabled by default)
|
||||
property string purity: "100" // sfw
|
||||
property string sorting: "relevance" // date_added, relevance, random, views, favorites, toplist
|
||||
property string order: "desc" // desc, asc
|
||||
property string topRange: "1M" // 1d, 3d, 1w, 1M, 3M, 6M, 1y
|
||||
property string seed: "" // For random sorting
|
||||
property string minResolution: "" // e.g., "1920x1080"
|
||||
property string resolutions: "" // e.g., "1920x1080,1920x1200"
|
||||
property string ratios: "" // e.g., "16x9,16x10"
|
||||
property string colors: "" // Color hex codes
|
||||
// API Key Priority: Environment Variable > Local Settings
|
||||
readonly property string envApiKey: Quickshell.env("NOCTALIA_WALLHAVEN_API_KEY") || ""
|
||||
readonly property string apiKey: envApiKey !== "" ? envApiKey : (Settings.data.wallpaper.wallhavenApiKey || "")
|
||||
readonly property bool apiKeyManagedByEnv: envApiKey !== ""
|
||||
|
||||
// Signals
|
||||
signal searchCompleted(var results, var meta)
|
||||
signal searchFailed(string error)
|
||||
signal wallpaperDownloaded(string wallpaperId, string localPath)
|
||||
|
||||
// Base API URL
|
||||
readonly property string apiBaseUrl: "https://wallhaven.cc/api/v1"
|
||||
|
||||
// -------------------------------------------------
|
||||
function search(query, page) {
|
||||
if (fetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset initial search flag once we start a search
|
||||
if (initialSearchScheduled) {
|
||||
initialSearchScheduled = false;
|
||||
}
|
||||
|
||||
fetching = true;
|
||||
lastError = "";
|
||||
currentQuery = query || "";
|
||||
currentPage = page || 1;
|
||||
|
||||
var url = apiBaseUrl + "/search";
|
||||
var params = [];
|
||||
|
||||
if (currentQuery) {
|
||||
params.push("q=" + encodeURIComponent(currentQuery));
|
||||
}
|
||||
|
||||
params.push("categories=" + categories);
|
||||
// Safety: Force SFW if no purity selected to prevent API error
|
||||
var safePurity = (purity === "000") ? "100" : purity;
|
||||
params.push("purity=" + safePurity);
|
||||
params.push("sorting=" + sorting);
|
||||
params.push("order=" + order);
|
||||
|
||||
if (sorting === "toplist") {
|
||||
params.push("topRange=" + topRange);
|
||||
}
|
||||
|
||||
if (sorting === "random" && seed) {
|
||||
params.push("seed=" + seed);
|
||||
}
|
||||
|
||||
if (minResolution) {
|
||||
params.push("atleast=" + minResolution);
|
||||
}
|
||||
|
||||
if (resolutions) {
|
||||
params.push("resolutions=" + resolutions);
|
||||
}
|
||||
|
||||
if (ratios) {
|
||||
params.push("ratios=" + ratios);
|
||||
}
|
||||
|
||||
if (colors) {
|
||||
params.push("colors=" + colors);
|
||||
}
|
||||
|
||||
if (apiKey) {
|
||||
params.push("apikey=" + apiKey);
|
||||
}
|
||||
|
||||
params.push("page=" + currentPage);
|
||||
|
||||
url += "?" + params.join("&");
|
||||
|
||||
Logger.d("Wallhaven", "Searching:", url);
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||
fetching = false;
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
var response = JSON.parse(xhr.responseText);
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
currentResults = response.data;
|
||||
currentMeta = response.meta || {};
|
||||
lastPage = currentMeta.last_page || 1;
|
||||
|
||||
// Store seed for random sorting
|
||||
if (currentMeta.seed) {
|
||||
seed = currentMeta.seed;
|
||||
}
|
||||
|
||||
Logger.d("Wallhaven", "Search completed:", currentResults.length, "results, page", currentPage, "of", lastPage);
|
||||
searchCompleted(currentResults, currentMeta);
|
||||
} else {
|
||||
var errorMsg = "Invalid API response";
|
||||
lastError = errorMsg;
|
||||
Logger.e("Wallhaven", errorMsg);
|
||||
searchFailed(errorMsg);
|
||||
}
|
||||
} catch (e) {
|
||||
var errorMsg = "Failed to parse API response: " + e.toString();
|
||||
lastError = errorMsg;
|
||||
Logger.e("Wallhaven", errorMsg);
|
||||
searchFailed(errorMsg);
|
||||
}
|
||||
} else if (xhr.status === 429) {
|
||||
var errorMsg = "Rate limit exceeded (45 requests/minute)";
|
||||
lastError = errorMsg;
|
||||
Logger.w("Wallhaven", errorMsg);
|
||||
searchFailed(errorMsg);
|
||||
} else if (xhr.status === 401) {
|
||||
var errorMsg = "Invalid API Key. Please check your settings.";
|
||||
lastError = errorMsg;
|
||||
Logger.e("Wallhaven", errorMsg);
|
||||
searchFailed(errorMsg);
|
||||
} else {
|
||||
var errorMsg = "API error: " + xhr.status;
|
||||
lastError = errorMsg;
|
||||
Logger.e("Wallhaven", "Search failed:", errorMsg);
|
||||
searchFailed(errorMsg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
xhr.open("GET", url);
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
function getWallpaperUrl(wallpaper) {
|
||||
// Use the 'path' field which contains the full resolution image URL
|
||||
if (wallpaper.path) {
|
||||
return wallpaper.path;
|
||||
}
|
||||
// Fallback to constructing URL from ID
|
||||
if (wallpaper.id) {
|
||||
var idPrefix = wallpaper.id.substring(0, 2);
|
||||
return "https://w.wallhaven.cc/full/" + idPrefix + "/wallhaven-" + wallpaper.id + ".jpg";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
function getThumbnailUrl(wallpaper, size) {
|
||||
// size: "small", "large", "original"
|
||||
if (wallpaper.thumbs && wallpaper.thumbs[size]) {
|
||||
return wallpaper.thumbs[size];
|
||||
}
|
||||
// Fallback
|
||||
if (wallpaper.id) {
|
||||
var idPrefix = wallpaper.id.substring(0, 2);
|
||||
var sizeMap = {
|
||||
"small": "small",
|
||||
"large": "lg",
|
||||
"original": "orig"
|
||||
};
|
||||
var sizePath = sizeMap[size] || "lg";
|
||||
return "https://th.wallhaven.cc/" + sizePath + "/" + idPrefix + "/" + wallpaper.id + ".jpg";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
function downloadWallpaper(wallpaper, callback) {
|
||||
var url = getWallpaperUrl(wallpaper);
|
||||
if (!url) {
|
||||
Logger.e("Wallhaven", "No URL available for wallpaper", wallpaper.id);
|
||||
if (callback)
|
||||
callback(false, "");
|
||||
return;
|
||||
}
|
||||
|
||||
var wallpaperId = wallpaper.id;
|
||||
|
||||
// Get the user's wallpaper directory
|
||||
var wallpaperDir = Settings.preprocessPath(Settings.data.wallpaper.directory);
|
||||
if (!wallpaperDir || wallpaperDir === "") {
|
||||
wallpaperDir = Settings.defaultWallpapersDirectory;
|
||||
}
|
||||
|
||||
// Ensure directory ends with /
|
||||
if (!wallpaperDir.endsWith("/")) {
|
||||
wallpaperDir += "/";
|
||||
}
|
||||
|
||||
var localPath = wallpaperDir + "wallhaven_" + wallpaperId + ".jpg";
|
||||
|
||||
Logger.d("Wallhaven", "Downloading wallpaper", wallpaperId, "to", localPath);
|
||||
|
||||
// Use curl or wget to download the file, ensuring directory exists first
|
||||
var downloadProcess = Qt.createQmlObject(`
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
Process {
|
||||
id: downloadProcess
|
||||
command: ["sh", "-c", "mkdir -p '` + wallpaperDir + `' && (curl -L -s -o '` + localPath + `' '` + url + `' || wget -q -O '` + localPath + `' '` + url + `')"]
|
||||
}
|
||||
`, root, "DownloadProcess_" + wallpaperId);
|
||||
|
||||
downloadProcess.exited.connect(function (exitCode) {
|
||||
if (exitCode === 0) {
|
||||
Logger.i("Wallhaven", "Wallpaper downloaded:", localPath);
|
||||
wallpaperDownloaded(wallpaperId, localPath);
|
||||
if (callback)
|
||||
callback(true, localPath);
|
||||
} else {
|
||||
Logger.e("Wallhaven", "Failed to download wallpaper, exit code:", exitCode);
|
||||
if (callback)
|
||||
callback(false, "");
|
||||
}
|
||||
downloadProcess.destroy();
|
||||
});
|
||||
|
||||
downloadProcess.running = true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
function reset() {
|
||||
currentResults = [];
|
||||
currentMeta = {};
|
||||
currentQuery = "";
|
||||
currentPage = 1;
|
||||
lastPage = 1;
|
||||
seed = "";
|
||||
lastError = "";
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
function nextPage() {
|
||||
if (currentPage < lastPage && !fetching) {
|
||||
search(currentQuery, currentPage + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
function previousPage() {
|
||||
if (currentPage > 1 && !fetching) {
|
||||
search(currentQuery, currentPage - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user